refactor: restructure project into a workspace by introducing a plugin API crate and modularizing individual plugin definitions.
Release / check-release (push) Successful in 17s
Release / build (push) Skipped
Release / package (deb) (push) Skipped
Release / package (rpm) (push) Skipped
Release / publish (push) Skipped
CI / test (push) Failing after 2m6s

This commit is contained in:
Elias Wendland
2026-07-17 15:50:50 +02:00
parent 2ea317e117
commit eb76ed1937
43 changed files with 4243 additions and 2296 deletions
+13 -16
View File
@@ -2,28 +2,25 @@ name: CI
on:
push:
branches: [ "main" ]
branches: ["main"]
pull_request:
branches: [ "main" ]
branches: ["main"]
env:
CARGO_TERM_COLOR: always
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run tests
run: cargo test
build-linux:
name: Build Linux
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Build
run: cargo build --release
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo test --workspace --locked
- run: cargo build --release --workspace --locked --target x86_64-unknown-linux-gnu
- name: Verify plugin-free recommendation
run: |
test_dir=$(mktemp -d)
cp target/x86_64-unknown-linux-gnu/release/convertis "$test_dir/"
output=$("$test_dir/convertis" --no-default-plugins assets/example.avif "$test_dir/example.png" 2>&1 || true)
echo "$output"
echo "$output" | grep -q "Install: convertis-imagemagick"
+74 -297
View File
@@ -2,7 +2,7 @@ name: Release
on:
push:
branches: [ "main" ]
branches: ["main"]
jobs:
check-release:
@@ -12,27 +12,22 @@ jobs:
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Check commit message
- name: Check release commit and version
id: check
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: |
if echo "$COMMIT_MSG" | grep -Eq 'Release [vV]?[0-9]+\.[0-9]+\.[0-9]+'; then
# Strips "Release " and an optional "v" or "V" to isolate just the numbers
VERSION=$(echo "$COMMIT_MSG" | grep -Eo 'Release [vV]?[0-9]+\.[0-9]+\.[0-9]+' | head -n1 | sed -E 's/Release [vV]?//')
PACKAGE_VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)
if [ "$VERSION" != "$PACKAGE_VERSION" ]; then
echo "Release version $VERSION does not match Cargo.toml version $PACKAGE_VERSION" >&2
exit 1
fi
echo "match=true" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
version=$(echo "$COMMIT_MSG" | grep -Eo 'Release [vV]?[0-9]+\.[0-9]+\.[0-9]+' | head -n1 | sed -E 's/Release [vV]?//')
package_version=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -n1)
test "$version" = "$package_version"
echo "match=true" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
else
echo "match=false" >> $GITHUB_OUTPUT
echo "match=false" >> "$GITHUB_OUTPUT"
fi
build_gnu:
build:
needs: check-release
if: needs.check-release.outputs.match == 'true'
runs-on: ubuntu-latest
@@ -41,268 +36,79 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Build
run: cargo build --release --target x86_64-unknown-linux-gnu
- name: Rename Raw Executable
run: cp target/x86_64-unknown-linux-gnu/release/convertis convertis-x86_64-unknown-linux-gnu
- name: Upload Binary for Packaging
uses: actions/upload-artifact@v3
- uses: Swatinem/rust-cache@v2
- run: cargo test --workspace --locked
- run: cargo build --release --workspace --locked --target x86_64-unknown-linux-gnu
- uses: actions/upload-artifact@v3
with:
name: build-gnu
name: build-linux
if-no-files-found: error
path: |
target/x86_64-unknown-linux-gnu/release/convertis
target/x86_64-unknown-linux-gnu/release/libconvertis_*.so
target/man/convertis.1
- name: Upload Raw Executable Artifact
uses: actions/upload-artifact@v3
with:
name: raw-gnu
path: convertis-x86_64-unknown-linux-gnu
build_musl:
needs: check-release
if: needs.check-release.outputs.match == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- name: Install cross-platform linkers
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
- name: Build
run: cargo build --release --target x86_64-unknown-linux-musl
- name: Rename Raw Executable
run: cp target/x86_64-unknown-linux-musl/release/convertis convertis-x86_64-unknown-linux-musl
- name: Upload Binary for Packaging
uses: actions/upload-artifact@v3
with:
name: build-musl
path: |
target/x86_64-unknown-linux-musl/release/convertis
target/man/convertis.1
- name: Upload Raw Executable Artifact
uses: actions/upload-artifact@v3
with:
name: raw-musl
path: convertis-x86_64-unknown-linux-musl
build_windows:
needs: check-release
if: needs.check-release.outputs.match == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-gnu
- name: Install cross-platform linkers
run: |
sudo apt-get update
sudo apt-get install -y mingw-w64
- name: Build
run: cargo build --release --target x86_64-pc-windows-gnu
- name: Rename Raw Executable
run: cp target/x86_64-pc-windows-gnu/release/convertis.exe convertis-x86_64-pc-windows-gnu.exe
- name: Upload Raw Executable Artifact
uses: actions/upload-artifact@v3
with:
name: raw-windows
path: convertis-x86_64-pc-windows-gnu.exe
package_gnu:
needs: [check-release, build_gnu]
if: needs.check-release.outputs.match == 'true'
package:
needs: [check-release, build]
strategy:
matrix:
type: [deb, rpm]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-go@v5
with:
targets: x86_64-unknown-linux-gnu
- name: Cache cargo
uses: Swatinem/rust-cache@v2
go-version: stable
- name: Install nFPM
run: go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.47.0
- uses: actions/download-artifact@v3
with:
key: package-${{ matrix.type }}-gnu
- name: Install cargo-deb
if: matrix.type == 'deb'
uses: taiki-e/install-action@v2
with:
tool: cargo-deb
- name: Install cargo-generate-rpm
if: matrix.type == 'rpm'
run: cargo install cargo-generate-rpm
- name: Download compiled binary
uses: actions/download-artifact@v3
with:
name: build-gnu
name: build-linux
path: target/
- name: Build package
- name: Build packages
run: |
mkdir -p dist/
sed -i '/\[package\.metadata\.deb\]/a name = "convertis-gnu"' Cargo.toml
sed -i '/\[package\.metadata\.generate-rpm\]/a name = "convertis-gnu"' Cargo.toml
if [ "${{ matrix.type }}" = "deb" ]; then
cargo deb --no-build --target x86_64-unknown-linux-gnu
deb_file=$(ls target/x86_64-unknown-linux-gnu/debian/*.deb)
cp "$deb_file" "dist/$(basename "$deb_file" .deb)-gnu.deb"
elif [ "${{ matrix.type }}" = "rpm" ]; then
cargo generate-rpm --target x86_64-unknown-linux-gnu
rpm_file=$(ls target/x86_64-unknown-linux-gnu/generate-rpm/*.rpm)
cp "$rpm_file" "dist/$(basename "$rpm_file" .rpm)-gnu.rpm"
fi
export PATH="$HOME/go/bin:$PATH"
bash packaging/build-packages.sh "${{ matrix.type }}" "${{ needs.check-release.outputs.version }}" target/x86_64-unknown-linux-gnu/release dist
- name: Import GPG key
if: matrix.type == 'rpm'
uses: crazy-max/ghaction-import-gpg@v6
id: import-gpg
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Sign packages
- name: Sign and verify RPMs
if: matrix.type == 'rpm'
env:
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
sudo apt-get update
sudo apt-get install -y rpm
PASSPHRASE_FILE=$(mktemp)
PUBLIC_KEY_FILE=$(mktemp)
trap 'rm -f "$PASSPHRASE_FILE" "$PUBLIC_KEY_FILE"' EXIT
printf '%s' "$GPG_PASSPHRASE" > "$PASSPHRASE_FILE"
chmod 600 "$PASSPHRASE_FILE"
passphrase_file=$(mktemp)
public_key_file=$(mktemp)
trap 'rm -f "$passphrase_file" "$public_key_file"' EXIT
printf '%s' "$GPG_PASSPHRASE" > "$passphrase_file"
chmod 600 "$passphrase_file"
echo "%_signature gpg" > ~/.rpmmacros
echo "%_gpg_name ${{ steps.import-gpg.outputs.keyid }}" >> ~/.rpmmacros
echo "%_gpg_path $HOME/.gnupg" >> ~/.rpmmacros
echo "%_gpg_passphrase_file $PASSPHRASE_FILE" >> ~/.rpmmacros
echo "%_gpg_passphrase_file $passphrase_file" >> ~/.rpmmacros
echo '%__gpg_sign_cmd /usr/bin/gpg --batch --yes --pinentry-mode loopback --passphrase-file %{_gpg_passphrase_file} --no-verbose --no-armor --no-secmem-warning -u %{_gpg_name} -sbo %{__signature_filename} -- %{__plaintext_filename}' >> ~/.rpmmacros
rpm --addsign dist/*.rpm
gpg --armor --export "${{ steps.import-gpg.outputs.keyid }}" > "$PUBLIC_KEY_FILE"
sudo rpm --import "$PUBLIC_KEY_FILE"
SIGNATURE_CHECK=$(rpm --checksig --verbose dist/*.rpm)
echo "$SIGNATURE_CHECK"
echo "$SIGNATURE_CHECK" | grep -Eq '[Ss]ignature.*: OK'
- name: Upload Package Artifact
uses: actions/upload-artifact@v3
gpg --armor --export "${{ steps.import-gpg.outputs.keyid }}" > "$public_key_file"
sudo rpm --import "$public_key_file"
signature_check=$(rpm --checksig --verbose dist/*.rpm)
echo "$signature_check"
signed_count=$(echo "$signature_check" | grep -Ec '[Ss]ignature.*: OK')
package_count=$(find dist -maxdepth 1 -name '*.rpm' | wc -l)
test "$signed_count" -ge "$package_count"
- uses: actions/upload-artifact@v3
with:
name: pkg-${{ matrix.type }}-gnu
name: packages-${{ matrix.type }}
if-no-files-found: error
path: dist/*
package_musl:
needs: [check-release, build_musl]
if: needs.check-release.outputs.match == 'true'
strategy:
matrix:
type: [deb, rpm]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
key: package-${{ matrix.type }}-musl
- name: Install cargo-deb
if: matrix.type == 'deb'
uses: taiki-e/install-action@v2
with:
tool: cargo-deb
- name: Install cargo-generate-rpm
if: matrix.type == 'rpm'
run: cargo install cargo-generate-rpm
- name: Download compiled binary
uses: actions/download-artifact@v3
with:
name: build-musl
path: target/
- name: Build package
run: |
mkdir -p dist/
sed -i '/\[package\.metadata\.deb\]/a name = "convertis-musl"' Cargo.toml
sed -i '/\[package\.metadata\.generate-rpm\]/a name = "convertis-musl"' Cargo.toml
if [ "${{ matrix.type }}" = "deb" ]; then
cargo deb --no-build --target x86_64-unknown-linux-musl
deb_file=$(ls target/x86_64-unknown-linux-musl/debian/*.deb)
cp "$deb_file" "dist/$(basename "$deb_file" .deb)-musl.deb"
elif [ "${{ matrix.type }}" = "rpm" ]; then
cargo generate-rpm --target x86_64-unknown-linux-musl
rpm_file=$(ls target/x86_64-unknown-linux-musl/generate-rpm/*.rpm)
cp "$rpm_file" "dist/$(basename "$rpm_file" .rpm)-musl.rpm"
fi
- name: Import GPG key
if: matrix.type == 'rpm'
uses: crazy-max/ghaction-import-gpg@v6
id: import-gpg
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Sign packages
if: matrix.type == 'rpm'
env:
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
sudo apt-get update
sudo apt-get install -y rpm
PASSPHRASE_FILE=$(mktemp)
PUBLIC_KEY_FILE=$(mktemp)
trap 'rm -f "$PASSPHRASE_FILE" "$PUBLIC_KEY_FILE"' EXIT
printf '%s' "$GPG_PASSPHRASE" > "$PASSPHRASE_FILE"
chmod 600 "$PASSPHRASE_FILE"
echo "%_signature gpg" > ~/.rpmmacros
echo "%_gpg_name ${{ steps.import-gpg.outputs.keyid }}" >> ~/.rpmmacros
echo "%_gpg_path $HOME/.gnupg" >> ~/.rpmmacros
echo "%_gpg_passphrase_file $PASSPHRASE_FILE" >> ~/.rpmmacros
echo '%__gpg_sign_cmd /usr/bin/gpg --batch --yes --pinentry-mode loopback --passphrase-file %{_gpg_passphrase_file} --no-verbose --no-armor --no-secmem-warning -u %{_gpg_name} -sbo %{__signature_filename} -- %{__plaintext_filename}' >> ~/.rpmmacros
rpm --addsign dist/*.rpm
gpg --armor --export "${{ steps.import-gpg.outputs.keyid }}" > "$PUBLIC_KEY_FILE"
sudo rpm --import "$PUBLIC_KEY_FILE"
SIGNATURE_CHECK=$(rpm --checksig --verbose dist/*.rpm)
echo "$SIGNATURE_CHECK"
echo "$SIGNATURE_CHECK" | grep -Eq '[Ss]ignature.*: OK'
- name: Upload Package Artifact
uses: actions/upload-artifact@v3
with:
name: pkg-${{ matrix.type }}-musl
path: dist/*
publish-release:
needs: [check-release, build_windows, package_gnu, package_musl]
publish:
needs: [check-release, build, package]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -311,73 +117,44 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download All Artifacts
uses: actions/download-artifact@v3
- uses: actions/download-artifact@v3
with:
path: all-packages/
path: all-artifacts
merge-multiple: false
- name: Cleanup internal build artifacts
- name: Prepare release assets
run: |
rm -rf all-packages/build-gnu
rm -rf all-packages/build-musl
- name: Generate Changelog
mkdir -p release-assets/plugins
binary=$(find all-artifacts/build-linux -type f -name convertis | head -n1)
cp "$binary" release-assets/convertis-x86_64-unknown-linux-gnu
find all-artifacts/build-linux -type f -name 'libconvertis_*.so' -exec cp {} release-assets/plugins/ \;
cd release-assets
zip -r "convertis-plugins-${{ needs.check-release.outputs.version }}-x86_64-unknown-linux-gnu.zip" plugins
- name: Generate changelog
run: |
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD)
git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%an)" > commits.txt
echo "## Changelog" > changelog.md
echo "### Features" >> changelog.md
grep -i "^- feat:" commits.txt >> changelog.md || echo "No new features" >> changelog.md
echo "### Fixes" >> changelog.md
grep -i "^- fix:" commits.txt >> changelog.md || echo "No fixes" >> changelog.md
echo "### Refactoring & Chores" >> changelog.md
grep -i "^- refactor:\|^- chore:\|^- style:" commits.txt >> changelog.md || echo "No refactoring or chores" >> changelog.md
echo "### Others" >> changelog.md
grep -vi "^- feat:\|^- fix:\|^- refactor:\|^- chore:\|^- style:" commits.txt >> changelog.md || echo "No other changes" >> changelog.md
- name: Create Gitea Release
last_tag=$(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD)
git log "${last_tag}..HEAD" --pretty=format:'- %s (%an)' > changelog.md
- name: Create Gitea release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.check-release.outputs.version }}
name: Release v${{ needs.check-release.outputs.version }}
body_path: changelog.md
files: all-packages/**/*
files: |
release-assets/convertis-x86_64-unknown-linux-gnu
release-assets/convertis-plugins-${{ needs.check-release.outputs.version }}-x86_64-unknown-linux-gnu.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish Packages to Gitea Registry
- name: Publish packages to Gitea registries
env:
GITEA_URL: ${{ github.server_url }}
GITEA_OWNER: ${{ github.repository_owner }}
TOKEN: ${{ secrets.PACKAGE_PAT }}
run: |
GITEA_URL="${{ github.server_url }}"
GITEA_OWNER="${{ github.repository_owner }}"
TOKEN="${{ secrets.PACKAGE_PAT }}"
echo "Publishing RPM packages..."
find all-packages/ -type f -name "*.rpm" | while read -r rpm_file; do
echo "Uploading $rpm_file..."
HTTP_CODE=$(curl -sS -w "%{http_code}" -o /dev/null -u "${{ github.actor }}:$TOKEN" \
--upload-file "$rpm_file" \
"$GITEA_URL/api/packages/$GITEA_OWNER/rpm/upload")
if [ "$HTTP_CODE" -ne 201 ] && [ "$HTTP_CODE" -ne 409 ]; then
echo "Upload failed with HTTP $HTTP_CODE"
exit 1
fi
find all-artifacts/packages-rpm -type f -name '*.rpm' | while read -r package; do
code=$(curl -sS -w '%{http_code}' -o /dev/null -u "${{ github.actor }}:$TOKEN" --upload-file "$package" "$GITEA_URL/api/packages/$GITEA_OWNER/rpm/upload")
test "$code" = 201 || test "$code" = 409
done
echo "Publishing DEB packages..."
find all-packages/ -type f -name "*.deb" | while read -r deb_file; do
echo "Uploading $deb_file..."
HTTP_CODE=$(curl -sS -w "%{http_code}" -o /dev/null -u "${{ github.actor }}:$TOKEN" \
--upload-file "$deb_file" \
"$GITEA_URL/api/packages/$GITEA_OWNER/debian/pool/debian/main/upload")
if [ "$HTTP_CODE" -ne 201 ] && [ "$HTTP_CODE" -ne 409 ]; then
echo "Upload failed with HTTP $HTTP_CODE"
exit 1
fi
find all-artifacts/packages-deb -type f -name '*.deb' | while read -r package; do
code=$(curl -sS -w '%{http_code}' -o /dev/null -u "${{ github.actor }}:$TOKEN" --upload-file "$package" "$GITEA_URL/api/packages/$GITEA_OWNER/debian/pool/debian/main/upload")
test "$code" = 201 || test "$code" = 409
done
-1
View File
@@ -1,5 +1,4 @@
/target
AGENT.md
ARCHITECTURE.md
Cargo.lock
examples
Generated
+1533
View File
File diff suppressed because it is too large Load Diff
+45 -39
View File
@@ -1,41 +1,46 @@
[workspace]
members = [
".",
"crates/convertis-plugin-api",
"plugins/ffmpeg-audio",
"plugins/ffmpeg-video",
"plugins/ffmpeg-video-to-frames",
"plugins/ffmpeg-frames-to-video",
"plugins/native-image",
"plugins/image-ascii",
"plugins/html",
"plugins/imagemagick",
"plugins/graphicsmagick",
]
resolver = "2"
[workspace.package]
version = "0.3.0"
edition = "2024"
authors = ["Elias Wendland <eliaswendland@pm.me>"]
license = "GPL-3.0-only"
repository = "https://git.ewenlau.net/ewenlau/convertis"
[workspace.dependencies]
convertis-plugin-api = { path = "crates/convertis-plugin-api", version = "=0.3.0" }
base64 = "0.22"
image = "0.25.10"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tempfile = "3.27.0"
[package]
name = "convertis"
version = "0.2.0"
edition = "2024"
description = "A program attempting to be a universal file converter"
repository = "https://git.ewenlau.net/ewenlau/convertis"
authors = ["Elias Wendland <eliaswendland@pm.me>"]
license = "GPL-3.0-only"
version.workspace = true
edition.workspace = true
description = "A modular universal file converter"
repository.workspace = true
authors.workspace = true
license.workspace = true
readme = "README.md"
keywords = ["converter", "ffmpeg", "image", "video", "audio"]
categories = ["command-line-utilities", "development-tools", "multimedia", "command-line-interface"]
exclude = [
".github/",
".gitea/",
"target/",
"*.log"
]
[package.metadata.deb]
maintainer = "Elias Wendland <eliaswendland@pm.me>"
copyright = "2026, Elias Wendland"
depends = "$auto"
section = "utility"
priority = "optional"
assets = [
["target/release/convertis", "usr/bin/", "755"],
["README.md", "usr/share/doc/convertis/README", "644"],
["target/man/convertis.1", "usr/share/man/man1/convertis.1", "644"]
]
[package.metadata.generate-rpm]
assets = [
{ source = "target/release/convertis", dest = "/usr/bin/convertis", mode = "755" },
{ source = "target/man/convertis.1", dest = "/usr/share/man/man1/convertis.1", mode = "644" }
]
[package.metadata.generate-rpm.requires]
categories = ["command-line-utilities", "multimedia"]
exclude = [".github/", "target/", "*.log"]
[build-dependencies]
clap = { version = "4.6.1", features = ["derive"] }
@@ -44,11 +49,12 @@ tracing = "0.1.44"
[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
image = "0.25.10"
convertis-plugin-api.workspace = true
infer = "0.16.0"
petgraph = "0.8.3"
tempfile = "3.27.0"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread"] }
gif = "0.14"
libloading = "0.8"
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
+50 -80
View File
@@ -1,111 +1,81 @@
# Convertis
## Description
Convertis is a CLI tool to convert files from one format to another. It's main design goal is to be able to convert *any* format to *any* other format, including different types, like MP4 to JPEG, MP4 to MP3 and in the future more exotic paths like HTML to JPEG or PNG to ZIP.
As it attemps to effectively cover an infinite number of possible conversions, it uses a pathfinding algorithm, that may include multiple conversions before reaching the target goal.
Convertis 0.3 is a modular Linux CLI for routing files through independently installed conversion plugins. The base `convertis` package contains the engine and no converters.
## Installation
Convertis can be installed in a few ways.
Note: Convertis has three different targets "gnu", "musl" and "windows". The "gnu" build targets regular GNU/Linux systems, the "musl" build targets systems using the musl C standard library and the "windows" build targets windows systems. Only the "gnu" build is actively tested.
### Via repository
Convertis is available on GNU/Linux for Debian-based systems and Fedora/CentOS-based systems via my gitea repository.
Packages are published for both the gnu and musl targets, although the gnu build should always be preferred.
#### RPM
Add the RPM repo via the .repo file:
On RedHat distros:
Install the engine first:
```sh
dnf config-manager --add-repo https://git.ewenlau.net/ewenlau/convertis/raw/branch/main/repo/gitea-ewenlau.repo
sudo apt install convertis
# or
sudo dnf install convertis
```
On Fedora 41+:
Then install only the plugins you need. Each plugin is a separate package:
```sh
dnf config-manager addrepo --from-repofile=https://git.ewenlau.net/ewenlau/convertis/raw/branch/main/repo/gitea-ewenlau.repo
sudo apt install convertis-native-image
sudo apt install convertis-ffmpeg-video convertis-ffmpeg-audio
```
On SUSE distros:
Convenience bundles are available:
```sh
zypper addrepo -f https://git.ewenlau.net/ewenlau/convertis/raw/branch/main/repo/gitea-ewenlau.repo
```
- `convertis-ffmpeg`: all FFmpeg plugins, including frame extraction and assembly.
- `convertis-image`: native image, ASCII, ImageMagick, and GraphicsMagick plugins.
- `convertis-plugins-all`: every official plugin.
Then, install the package:
When no installed route can perform a conversion, Convertis prints the individual package or packages that provide one.
On RedHat/Fedora:
```sh
dnf install convertis-gnu
```
### Raw release
On SUSE distros:
```sh
zypper install convertis-gnu
```
The release page contains a raw `x86_64-unknown-linux-gnu` executable and one ZIP containing all official `.so` plugins. Extract its `plugins/` directory beside the executable. Plugins can also be placed directly beside the executable, in `~/.local/lib/convertis/plugins`, or in `/usr/lib/convertis/plugins`.
#### APT
Add the repo:
```sh
sudo curl https://git.ewenlau.net/api/packages/ewenlau/debian/repository.key -o /etc/apt/keyrings/gitea-ewenlau.asc
echo "deb [signed-by=/etc/apt/keyrings/gitea-ewenlau.asc] https://git.ewenlau.net/api/packages/ewenlau/debian $distribution $component" | sudo tee -a /etc/apt/sources.list.d/gitea.list
sudo apt update
```
Then, install the package:
```sh
sudo apt install convertis-gnu
```
If you want to install a specific version, append =<version-number>-1. For instance, to install version 0.2.0:
```sh
sudo apt install convertis-gnu=0.2.0-1
```
### Manual package install
You may download a .deb file for Debian-based systems and a .rpm file for Fedora/CentOS-based systems from the releases page. You can then install the package using your system's package manager.
See <https://git.ewenlau.net/ewenlau/convertis/releases>
### Binary download
If you use Windows, do not use a supported linux system or do not want to install any packages, you can download a binary release from the releases page.
See <https://git.ewenlau.net/ewenlau/convertis/releases>
## Building
Building convertis is simple, you just need the Rust toolchain installed:
```sh
cargo build --release
```
Plugins use an exact-version Rust ABI. A plugin must have been built for the same Convertis release; incompatible libraries are rejected before loading.
## Usage
```sh
convertis <input> <output> [options]
convertis input.png output.ico
convertis input.mp4 frames/ --to frames
convertis frames/ output.mp4
convertis input.png output.txt --option width=120
```
Options are available with the --help flag:
Input formats are detected from file contents. The output extension indicates the requested target and can be overridden with `--to`. Use `--from` only when content detection cannot recognize a format.
Frame extraction creates numbered images and `.convertis-frames.json`, which preserves the source frame rate and timing for the frames-to-video plugin. External frame folders without metadata use lexical ordering and 30 FPS by default.
Useful inspection commands:
```sh
convertis --list-plugins
convertis --list-formats
convertis --help
```
Use `--no-default-plugins` with explicit `--plugin-dir` arguments to run in an isolated plugin environment.
Plugin settings use repeatable `--option key=value` arguments. A plugin-qualified key such as `ffmpeg-frames-to-video.fps=24` can disambiguate settings in a multi-plugin route.
## Building
Build the engine and every official plugin:
```sh
cargo build --release --workspace
```
The engine is `target/release/convertis`; plugins are `target/release/libconvertis_*.so`.
## Plugin API
The workspace crate `convertis-plugin-api` defines the exact-version Rust trait used by official plugins. Plugin metadata is checked through a small C-compatible entry point before the Rust factory is called. The official plugin crates are the reference examples; ABI compatibility across Convertis releases is not promised.
## Platforms
Version 0.3 supports 64-bit GNU/Linux. Windows and musl builds are intentionally sunset for now.
## License
Convertis is licensed under the GNU General Public License v3.0 exclusively. See LICENSE for more details.
Convertis is licensed under GPL-3.0-only. See [LICENSE](LICENSE).
+33 -94
View File
@@ -12,110 +12,49 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use std::env;
use std::fs;
use std::path::Path;
use std::process::Command;
use clap::CommandFactory;
use std::{env, fs, path::Path, process::Command};
#[path = "src/args.rs"]
mod args;
fn run_command(cmd: &str, args: &[&str]) -> Option<String> {
let output = Command::new(cmd).args(args).output().ok()?;
if output.status.success() {
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !s.is_empty() {
return Some(s);
}
}
None
fn command_output(command: &str, args: &[&str]) -> String {
Command::new(command)
.args(args)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.filter(|output| !output.is_empty())
.unwrap_or_else(|| "unknown".to_owned())
}
fn main() {
let out_dir = env::var_os("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("plugins_gen.rs");
// Read the plugins directory
let plugins_dir = Path::new("plugins");
let mut plugins_code = String::new();
let mut registry_add_code = String::new();
if plugins_dir.exists() {
for entry in fs::read_dir(plugins_dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("rs") {
let stem = path.file_stem().unwrap().to_str().unwrap();
// We'll use include! macro to include the file content directly.
// However, `include!` requires a valid path. The generated file is in OUT_DIR,
// so we need an absolute path or relative to the generated file.
let abs_path = path.canonicalize().unwrap();
let abs_path_str = abs_path.to_str().unwrap();
plugins_code.push_str(&format!(
"pub mod {} {{\n include!({:?});\n}}\n",
stem, abs_path_str
));
registry_add_code
.push_str(&format!("registry.push(Box::new({}::PluginImpl));\n", stem));
}
}
}
let full_code = format!(
"
{}
pub fn get_plugins() -> Vec<Box<dyn crate::plugin::Plugin>> {{
let mut registry: Vec<Box<dyn crate::plugin::Plugin>> = Vec::new();
{}
registry
}}
",
plugins_code, registry_add_code
let out_dir = env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo");
let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".to_owned());
let rustc_version = command_output(
&env::var("RUSTC").unwrap_or_else(|_| "rustc".to_owned()),
&["--version"],
);
fs::write(&dest_path, full_code).unwrap();
println!("cargo:rerun-if-changed=plugins");
// Add build metadata for versioning
let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "dev-unknown".to_string());
let build_time = run_command("date", &["-u", "+%Y-%m-%d %H:%M:%S UTC"])
.unwrap_or_else(|| "Unknown".to_string());
let git_commit = run_command("git", &["rev-parse", "--short", "HEAD"])
.unwrap_or_else(|| "Unknown".to_string());
let target = env::var("TARGET").unwrap_or_else(|_| "Unknown".to_string());
let profile = env::var("PROFILE").unwrap_or_else(|_| "Unknown".to_string());
let rustc_path = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
let rustc_version = run_command(&rustc_path, &["--version"])
.map(|s| s.split('\n').next().unwrap_or("").to_string())
.unwrap_or_else(|| "Unknown".to_string());
let host = env::var("HOST").unwrap_or_else(|_| "Unknown".to_string());
let authors = env::var("CARGO_PKG_AUTHORS").unwrap_or_else(|_| "Unknown".to_string());
let repository = env::var("CARGO_PKG_REPOSITORY").unwrap_or_else(|_| "Unknown".to_string());
let version_string = format!(
"{}\nbuild-time: {}\ncommit: {}\ntarget: {}\nhost: {}\nprofile: {}\nrustc: {}\nauthors: {}\nrepository: {}",
version, build_time, git_commit, target, host, profile, rustc_version, authors, repository
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_owned());
println!("cargo:rustc-env=CONVERTIS_RUSTC_VERSION={rustc_version}");
println!("cargo:rustc-env=CONVERTIS_TARGET={target}");
let version_text = format!(
"{}\nbuild-time: {}\ncommit: {}\ntarget: {}\nrustc: {}",
version,
command_output("date", &["-u", "+%Y-%m-%d %H:%M:%S UTC"]),
command_output("git", &["rev-parse", "--short", "HEAD"]),
target,
rustc_version,
);
fs::write(Path::new(&out_dir).join("version.txt"), version_text).unwrap();
let version_path = Path::new(&out_dir).join("version.txt");
fs::write(&version_path, &version_string).unwrap();
// Generate man page
let man_dir = Path::new("target").join("man");
fs::create_dir_all(&man_dir).unwrap();
let mut cmd = args::Args::command();
cmd = cmd.name("convertis").version(env!("CARGO_PKG_VERSION"));
let man = clap_mangen::Man::new(cmd);
let mut buffer: Vec<u8> = Default::default();
man.render(&mut buffer).unwrap();
let man_dir = Path::new("target/man");
fs::create_dir_all(man_dir).unwrap();
let command = args::Args::command()
.name("convertis")
.version(env!("CARGO_PKG_VERSION"));
let mut buffer = Vec::new();
clap_mangen::Man::new(command).render(&mut buffer).unwrap();
fs::write(man_dir.join("convertis.1"), buffer).unwrap();
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "convertis-plugin-api"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
serde.workspace = true
+126
View File
@@ -0,0 +1,126 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf};
pub const API_VERSION: u32 = 1;
pub const ENGINE_VERSION: &str = "0.3.0";
pub const MANIFEST_SYMBOL: &[u8] = b"convertis_plugin_manifest_v1\0";
pub const FACTORY_SYMBOL: &[u8] = b"convertis_plugin_create_v1\0";
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ArtifactKind {
File,
Directory,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum MediaKind {
Image,
Animation,
Video,
Audio,
Text,
Frames,
Document,
Unknown,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Conversion {
pub from: String,
pub to: String,
pub input_kind: ArtifactKind,
pub output_kind: ArtifactKind,
pub familiarity: u8,
pub quality: u8,
pub speed: u8,
}
impl Conversion {
pub fn file(from: &str, to: &str, scores: (u8, u8, u8)) -> Self {
Self {
from: from.to_owned(),
to: to.to_owned(),
input_kind: ArtifactKind::File,
output_kind: ArtifactKind::File,
familiarity: scores.0,
quality: scores.1,
speed: scores.2,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct OptionSpec {
pub name: String,
pub help: String,
pub default: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PluginMetadata {
pub id: String,
pub package: String,
pub description: String,
pub conversions: Vec<Conversion>,
pub options: Vec<OptionSpec>,
}
#[derive(Clone, Debug)]
pub struct ConversionRequest {
pub input: PathBuf,
pub output: PathBuf,
pub from: String,
pub to: String,
pub options: BTreeMap<String, String>,
}
pub trait Plugin: Send + Sync {
fn metadata(&self) -> PluginMetadata;
fn availability(&self) -> Result<(), String> {
Ok(())
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String>;
}
pub type PluginFactory = unsafe fn() -> Box<dyn Plugin>;
pub type PluginManifest = unsafe extern "C" fn() -> *const std::ffi::c_char;
#[macro_export]
macro_rules! export_plugin {
($plugin:expr, $id:literal) => {
#[unsafe(no_mangle)]
pub extern "C" fn convertis_plugin_manifest_v1() -> *const std::ffi::c_char {
static MANIFEST: &str = concat!(
"{\"api_version\":1,\"engine_version\":\"",
env!("CARGO_PKG_VERSION"),
"\",\"plugin_id\":\"",
$id,
"\",\"rustc_version\":\"",
env!("CONVERTIS_RUSTC_VERSION"),
"\",\"target\":\"",
env!("CONVERTIS_TARGET"),
"\"}\0"
);
MANIFEST.as_ptr().cast()
}
#[unsafe(no_mangle)]
pub fn convertis_plugin_create_v1() -> Box<dyn $crate::Plugin> {
Box::new($plugin)
}
};
}
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -euo pipefail
packager=${1:?packager must be deb or rpm}
version=${2:?version is required}
release_root=${3:?release target directory is required}
dist_dir=${4:-dist}
mkdir -p "$dist_dir"
if [[ "$packager" == "deb" ]]; then
architecture=amd64
engine_dependency="convertis (= ${version}-1)"
system_dependencies=("libc6" "libgcc-s1")
ffmpeg_dependencies=("ffmpeg")
imagemagick_dependencies=("imagemagick")
graphicsmagick_dependencies=("graphicsmagick")
else
architecture=amd64
engine_dependency="convertis = ${version}-1"
system_dependencies=("glibc" "libgcc")
ffmpeg_dependencies=("/usr/bin/ffmpeg" "/usr/bin/ffprobe")
imagemagick_dependencies=("/usr/bin/magick")
graphicsmagick_dependencies=("/usr/bin/gm")
fi
make_package() {
local name=$1 description=$2
shift 2
local config
config=$(mktemp)
local contents=()
while [[ $# -gt 0 && "$1" == content:* ]]; do
contents+=("${1#content:}")
shift
done
local dependencies=("$@")
{
echo "name: $name"
echo "arch: $architecture"
echo "platform: linux"
echo "version: $version"
echo "release: 1"
echo "section: utils"
echo "priority: optional"
echo "maintainer: Elias Wendland <eliaswendland@pm.me>"
echo "description: $description"
echo "license: GPL-3.0-only"
echo "homepage: https://git.ewenlau.net/ewenlau/convertis"
if [[ ${#contents[@]} -gt 0 ]]; then
echo "contents:"
for mapping in "${contents[@]}"; do
destination=${mapping#*::}
echo " - src: ${mapping%%::*}"
echo " dst: $destination"
echo " file_info:"
if [[ "$destination" == /usr/bin/* || "$destination" == *.so ]]; then
echo " mode: 0755"
else
echo " mode: 0644"
fi
done
fi
if [[ ${#dependencies[@]} -gt 0 ]]; then
echo "depends:"
for dependency in "${dependencies[@]}"; do
echo " - $dependency"
done
fi
} > "$config"
nfpm package --config "$config" --packager "$packager" --target "$dist_dir/"
rm -f "$config"
}
exact_dependency() {
if [[ "$packager" == "deb" ]]; then
printf '%s (= %s-1)' "$1" "$version"
else
printf '%s = %s-1' "$1" "$version"
fi
}
make_package convertis "Plugin-based universal file converter" \
"content:${release_root}/convertis::/usr/bin/convertis" \
"content:target/man/convertis.1::/usr/share/man/man1/convertis.1" \
"content:README.md::/usr/share/doc/convertis/README.md" \
"content:LICENSE::/usr/share/doc/convertis/LICENSE" \
"content:packaging/plugin-catalog.json::/usr/share/convertis/plugin-catalog.json" \
"${system_dependencies[@]}"
plugin_package() {
local package=$1 library=$2 description=$3 dependency_group=${4:-none}
local dependencies=("$engine_dependency")
case "$dependency_group" in
ffmpeg) dependencies+=("${ffmpeg_dependencies[@]}") ;;
imagemagick) dependencies+=("${imagemagick_dependencies[@]}") ;;
graphicsmagick) dependencies+=("${graphicsmagick_dependencies[@]}") ;;
esac
make_package "$package" "$description" \
"content:${release_root}/${library}::/usr/lib/convertis/plugins/${library}" \
"${dependencies[@]}"
}
plugin_package convertis-ffmpeg-audio libconvertis_ffmpeg_audio.so "FFmpeg audio plugin for Convertis" ffmpeg
plugin_package convertis-ffmpeg-video libconvertis_ffmpeg_video.so "FFmpeg video plugin for Convertis" ffmpeg
plugin_package convertis-ffmpeg-video-to-frames libconvertis_ffmpeg_video_to_frames.so "FFmpeg video-to-frames plugin for Convertis" ffmpeg
plugin_package convertis-ffmpeg-frames-to-video libconvertis_ffmpeg_frames_to_video.so "FFmpeg frames-to-video plugin for Convertis" ffmpeg
plugin_package convertis-native-image libconvertis_native_image.so "Native common-image plugin for Convertis"
plugin_package convertis-image-ascii libconvertis_image_ascii.so "Image-to-ASCII plugin for Convertis"
plugin_package convertis-html libconvertis_html.so "Self-contained HTML plugin for Convertis"
plugin_package convertis-imagemagick libconvertis_imagemagick.so "ImageMagick plugin for Convertis" imagemagick
plugin_package convertis-graphicsmagick libconvertis_graphicsmagick.so "GraphicsMagick plugin for Convertis" graphicsmagick
make_package convertis-ffmpeg "All official FFmpeg plugins for Convertis" \
"$engine_dependency" \
"$(exact_dependency convertis-ffmpeg-audio)" \
"$(exact_dependency convertis-ffmpeg-video)" \
"$(exact_dependency convertis-ffmpeg-video-to-frames)" \
"$(exact_dependency convertis-ffmpeg-frames-to-video)"
make_package convertis-image "All official image plugins for Convertis" \
"$engine_dependency" \
"$(exact_dependency convertis-native-image)" \
"$(exact_dependency convertis-image-ascii)" \
"$(exact_dependency convertis-imagemagick)" \
"$(exact_dependency convertis-graphicsmagick)"
make_package convertis-plugins-all "Every official Convertis plugin" \
"$engine_dependency" \
"$(exact_dependency convertis-ffmpeg)" \
"$(exact_dependency convertis-image)" \
"$(exact_dependency convertis-html)"
+15
View File
@@ -0,0 +1,15 @@
{
"schema_version": 1,
"engine_version": "0.3.0",
"plugins": [
{ "id": "ffmpeg-audio", "package": "convertis-ffmpeg-audio" },
{ "id": "ffmpeg-video", "package": "convertis-ffmpeg-video" },
{ "id": "ffmpeg-video-to-frames", "package": "convertis-ffmpeg-video-to-frames" },
{ "id": "ffmpeg-frames-to-video", "package": "convertis-ffmpeg-frames-to-video" },
{ "id": "native-image", "package": "convertis-native-image" },
{ "id": "image-ascii", "package": "convertis-image-ascii" },
{ "id": "html", "package": "convertis-html" },
{ "id": "imagemagick", "package": "convertis-imagemagick" },
{ "id": "graphicsmagick", "package": "convertis-graphicsmagick" }
]
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use std::process::Command;
fn main() {
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_owned());
let version = Command::new(rustc)
.arg("--version")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.unwrap_or_else(|| "unknown".to_owned());
let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".to_owned());
println!("cargo:rustc-env=CONVERTIS_RUSTC_VERSION={version}");
println!("cargo:rustc-env=CONVERTIS_TARGET={target}");
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "convertis-ffmpeg-audio"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
+83
View File
@@ -0,0 +1,83 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
use std::process::Command;
const FORMATS: &[&str] = &[
"mp3", "ogg", "aac", "m4a", "opus", "wma", "wav", "flac", "aiff", "au",
];
struct FfmpegAudio;
fn available(binary: &str) -> Result<(), String> {
Command::new(binary)
.arg("-version")
.output()
.map_err(|_| format!("'{binary}' is required"))?
.status
.success()
.then_some(())
.ok_or_else(|| format!("'{binary}' is unavailable"))
}
impl Plugin for FfmpegAudio {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "ffmpeg-audio".into(),
package: "convertis-ffmpeg-audio".into(),
description: "FFmpeg audio conversion".into(),
conversions: FORMATS
.iter()
.flat_map(|from| {
FORMATS
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, (240, 220, 190)))
})
.collect(),
options: vec![],
}
}
fn availability(&self) -> Result<(), String> {
available("ffmpeg")
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let codec: &[&str] = match request.to.as_str() {
"mp3" => &["-c:a", "libmp3lame", "-q:a", "2"],
"ogg" => &["-c:a", "libvorbis", "-q:a", "4"],
"aac" | "m4a" => &["-c:a", "aac", "-b:a", "192k"],
"opus" => &["-c:a", "libopus", "-b:a", "128k"],
"flac" => &["-c:a", "flac"],
"wav" | "aiff" | "au" => &["-c:a", "pcm_s16le"],
"wma" => &["-c:a", "wmav2", "-b:a", "192k"],
_ => &[],
};
let output = Command::new("ffmpeg")
.arg("-v")
.arg("error")
.arg("-y")
.arg("-i")
.arg(&request.input)
.args(codec)
.arg(&request.output)
.output()
.map_err(|error| error.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).into_owned())
}
}
}
convertis_plugin_api::export_plugin!(FfmpegAudio, "ffmpeg-audio");
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "convertis-ffmpeg-frames-to-video"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
serde.workspace = true
serde_json.workspace = true
+173
View File
@@ -0,0 +1,173 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{
ArtifactKind, Conversion, ConversionRequest, OptionSpec, Plugin, PluginMetadata,
};
use serde::Deserialize;
use std::{fs, path::PathBuf, process::Command};
const FORMATS: &[&str] = &[
"mp4",
"webm",
"mkv",
"avi",
"mov",
"animated-gif",
"m4v",
"mpeg",
"ogv",
"animated-webp",
];
struct FramesToVideo;
#[derive(Deserialize)]
struct FramesManifest {
pattern: String,
frame_rate: String,
#[serde(default)]
timestamps_seconds: Vec<f64>,
}
fn frame_files(directory: &std::path::Path) -> Result<Vec<PathBuf>, String> {
let mut files: Vec<_> = fs::read_dir(directory)
.map_err(|error| error.to_string())?
.flatten()
.map(|entry| entry.path())
.filter(|path| {
path.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| matches!(value, "png" | "jpg" | "jpeg" | "webp" | "bmp"))
})
.collect();
files.sort();
if files.is_empty() {
Err("frames directory contains no supported images".into())
} else {
Ok(files)
}
}
impl Plugin for FramesToVideo {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "ffmpeg-frames-to-video".into(),
package: "convertis-ffmpeg-frames-to-video".into(),
description: "Build video from frames".into(),
conversions: FORMATS
.iter()
.map(|to| {
let mut c = Conversion::file("frames", to, (255, 220, 180));
c.input_kind = ArtifactKind::Directory;
c
})
.collect(),
options: vec![OptionSpec {
name: "fps".into(),
help: "Override input FPS; folders without metadata default to 30".into(),
default: None,
}],
}
}
fn availability(&self) -> Result<(), String> {
Command::new("ffmpeg")
.arg("-version")
.output()
.map_err(|_| "'ffmpeg' is required".to_owned())
.map(|_| ())
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let manifest = fs::read(request.input.join(".convertis-frames.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<FramesManifest>(&bytes).ok());
let rate = request
.options
.get("fps")
.cloned()
.or_else(|| manifest.as_ref().map(|value| value.frame_rate.clone()))
.unwrap_or_else(|| "30".into());
let mut command = Command::new("ffmpeg");
command.args(["-v", "error", "-y"]);
let files = frame_files(&request.input)?;
let mut concat_file = None;
if let Some(manifest) = &manifest
&& !request.options.contains_key("fps")
&& manifest.timestamps_seconds.len() == files.len()
&& files.len() > 1
{
let path = request.output.with_extension("frames.txt");
let mut contents = String::new();
for (index, file) in files.iter().enumerate() {
let escaped = file.to_string_lossy().replace('\'', "'\\''");
contents.push_str(&format!("file '{escaped}'\n"));
if let Some(next) = manifest.timestamps_seconds.get(index + 1) {
contents.push_str(&format!(
"duration {}\n",
(next - manifest.timestamps_seconds[index]).max(0.001)
));
}
}
let last = files
.last()
.unwrap()
.to_string_lossy()
.replace('\'', "'\\''");
contents.push_str(&format!("file '{last}'\n"));
fs::write(&path, contents).map_err(|error| error.to_string())?;
command
.args(["-f", "concat", "-safe", "0", "-i"])
.arg(&path);
concat_file = Some(path);
} else if let Some(manifest) = &manifest {
command
.args(["-framerate", &rate, "-i"])
.arg(request.input.join(&manifest.pattern));
} else {
let extension = files[0]
.extension()
.and_then(|value| value.to_str())
.unwrap();
command.args(["-framerate", &rate]);
command
.args(["-pattern_type", "glob", "-i"])
.arg(request.input.join(format!("*.{extension}")));
}
match request.to.as_str() {
"webm" => {
command.args(["-c:v", "libvpx-vp9", "-pix_fmt", "yuv420p"]);
}
"gif" | "animated-gif" => {
command.args(["-vf", "scale=640:-1:flags=lanczos"]);
}
"webp" | "animated-webp" => {
command.args(["-c:v", "libwebp", "-loop", "0"]);
}
_ => {
command.args(["-c:v", "libx264", "-pix_fmt", "yuv420p"]);
}
}
let output = command
.arg(&request.output)
.output()
.map_err(|error| error.to_string())?;
if let Some(path) = concat_file {
let _ = fs::remove_file(path);
}
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).into_owned())
}
}
}
convertis_plugin_api::export_plugin!(FramesToVideo, "ffmpeg-frames-to-video");
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "convertis-ffmpeg-video-to-frames"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
serde.workspace = true
serde_json.workspace = true
+191
View File
@@ -0,0 +1,191 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{
ArtifactKind, Conversion, ConversionRequest, OptionSpec, Plugin, PluginMetadata,
};
use serde::Serialize;
use std::{fs, process::Command};
const FORMATS: &[&str] = &[
"mp4",
"webm",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"gif",
"animated-gif",
"m4v",
"mpeg",
"ogv",
"apng",
"webp",
"animated-webp",
];
struct VideoToFrames;
#[derive(Serialize)]
struct FramesManifest {
schema_version: u32,
pattern: String,
frame_format: String,
frame_count: usize,
frame_rate: String,
timestamps_seconds: Vec<f64>,
}
fn rate_value(rate: &str) -> f64 {
if let Some((numerator, denominator)) = rate.split_once('/') {
let numerator = numerator.parse::<f64>().unwrap_or(30.0);
let denominator = denominator.parse::<f64>().unwrap_or(1.0);
if denominator != 0.0 {
return numerator / denominator;
}
}
rate.parse().unwrap_or(30.0)
}
impl Plugin for VideoToFrames {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "ffmpeg-video-to-frames".into(),
package: "convertis-ffmpeg-video-to-frames".into(),
description: "Extract video frames".into(),
conversions: FORMATS
.iter()
.map(|from| {
let mut c = Conversion::file(from, "frames", (255, 230, 190));
c.output_kind = ArtifactKind::Directory;
c
})
.collect(),
options: vec![
OptionSpec {
name: "frame_format".into(),
help: "png, jpeg, or webp".into(),
default: Some("png".into()),
},
OptionSpec {
name: "fps".into(),
help: "Optional extraction FPS".into(),
default: None,
},
],
}
}
fn availability(&self) -> Result<(), String> {
for binary in ["ffmpeg", "ffprobe"] {
Command::new(binary)
.arg("-version")
.output()
.map_err(|_| format!("'{binary}' is required"))?;
}
Ok(())
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
fs::create_dir_all(&request.output).map_err(|error| error.to_string())?;
let format = request
.options
.get("frame_format")
.map(String::as_str)
.unwrap_or("png");
if !matches!(format, "png" | "jpeg" | "webp") {
return Err("frame_format must be png, jpeg, or webp".into());
}
let detected_rate = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=avg_frame_rate",
"-of",
"default=nw=1:nk=1",
])
.arg(&request.input)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "30/1".into());
let detected_timestamps: Vec<f64> = Command::new("ffprobe")
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"frame=best_effort_timestamp_time",
"-of",
"csv=p=0",
])
.arg(&request.input)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| {
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse().ok())
.collect()
})
.unwrap_or_default();
let rate = request.options.get("fps").cloned().unwrap_or(detected_rate);
let pattern = format!("frame_%08d.{format}");
let mut command = Command::new("ffmpeg");
command
.args(["-v", "error", "-y", "-i"])
.arg(&request.input);
if let Some(fps) = request.options.get("fps") {
command.args(["-vf", &format!("fps={fps}")]);
}
let output = command
.arg(request.output.join(&pattern))
.output()
.map_err(|error| error.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
let frame_count = fs::read_dir(&request.output)
.map_err(|error| error.to_string())?
.flatten()
.filter(|entry| entry.path().extension().is_some())
.count();
let fps = rate_value(&rate).max(0.001);
let timestamps_seconds =
if request.options.contains_key("fps") || detected_timestamps.len() != frame_count {
(0..frame_count).map(|index| index as f64 / fps).collect()
} else {
detected_timestamps
};
let manifest = FramesManifest {
schema_version: 1,
pattern,
frame_format: format.into(),
frame_count,
frame_rate: rate,
timestamps_seconds,
};
fs::write(
request.output.join(".convertis-frames.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.map_err(|error| error.to_string())
}
}
convertis_plugin_api::export_plugin!(VideoToFrames, "ffmpeg-video-to-frames");
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "convertis-ffmpeg-video"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
+122
View File
@@ -0,0 +1,122 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
use std::process::Command;
const INPUTS: &[&str] = &[
"mp4",
"webm",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"gif",
"animated-gif",
"m4v",
"mpeg",
"ogv",
"apng",
"webp",
"animated-webp",
];
const OUTPUTS: &[&str] = &[
"mp4",
"webm",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"animated-gif",
"m4v",
"mpeg",
"ogv",
"apng",
"animated-webp",
];
struct FfmpegVideo;
impl Plugin for FfmpegVideo {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "ffmpeg-video".into(),
package: "convertis-ffmpeg-video".into(),
description: "FFmpeg video conversion".into(),
conversions: INPUTS
.iter()
.flat_map(|from| {
OUTPUTS
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, (240, 210, 180)))
})
.collect(),
options: vec![],
}
}
fn availability(&self) -> Result<(), String> {
Command::new("ffmpeg")
.arg("-version")
.output()
.map_err(|_| "'ffmpeg' is required".to_owned())
.and_then(|output| {
output
.status
.success()
.then_some(())
.ok_or_else(|| "'ffmpeg' is unavailable".to_owned())
})
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let codec: &[&str] = match request.to.as_str() {
"mp4" | "mkv" | "mov" | "m4v" => &[
"-c:v", "libx264", "-crf", "23", "-c:a", "aac", "-pix_fmt", "yuv420p",
],
"webm" => &[
"-c:v",
"libvpx-vp9",
"-crf",
"30",
"-b:v",
"0",
"-c:a",
"libopus",
],
"avi" => &["-c:v", "mpeg4", "-qscale:v", "3", "-c:a", "libmp3lame"],
"gif" | "animated-gif" => &["-vf", "fps=15,scale=640:-1:flags=lanczos"],
"webp" | "animated-webp" => &["-c:v", "libwebp", "-q:v", "75", "-loop", "0"],
"apng" => &["-plays", "0"],
"mpeg" => &["-c:v", "mpeg2video", "-c:a", "mp2"],
_ => &[],
};
let output = Command::new("ffmpeg")
.arg("-v")
.arg("error")
.arg("-y")
.arg("-i")
.arg(&request.input)
.args(codec)
.arg(&request.output)
.output()
.map_err(|error| error.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).into_owned())
}
}
}
convertis_plugin_api::export_plugin!(FfmpegVideo, "ffmpeg-video");
-179
View File
@@ -1,179 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::io::Write;
use std::path::Path;
pub struct PluginImpl;
impl Plugin for PluginImpl {
fn name(&self) -> &'static str {
"ffmpeg_audio"
}
fn from_formats(&self) -> Vec<&'static str> {
vec![
"mp3", "ogg", "aac", "m4a", "opus", "wma", "mka", "mp2", "ra", "wav", "flac", "alac",
"aiff", "au", "caf", "w64", "ac3", "eac3", "dts", "truehd", "thd", "ape", "wv", "tta",
"amr", "spx", "gsm", "voc",
]
}
fn to_formats(&self) -> Vec<&'static str> {
vec![
"mp3", "ogg", "aac", "m4a", "opus", "wma", "mka", "mp2", "ra", "wav", "flac", "alac",
"aiff", "au", "caf", "w64", "ac3", "eac3", "dts", "truehd", "thd", "ape", "wv", "tta",
"amr", "spx", "gsm", "voc",
]
}
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability of ffmpeg for ffmpeg_audio plugin");
let available = std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_ok();
tracing::debug!("ffmpeg_audio plugin available: {}", available);
available
}
fn familiarity(&self, _from: &str, to: &str) -> u8 {
match to {
"mp3" | "wav" => 255,
"m4a" | "aac" | "flac" => 240,
"ogg" | "opus" | "alac" => 220,
"ac3" | "eac3" | "dts" => 180,
"wma" | "amr" | "mka" | "mp2" => 150,
"aiff" | "au" | "caf" | "w64" => 120,
"ape" | "wv" | "tta" | "truehd" | "thd" => 100,
"spx" | "gsm" | "voc" | "ra" => 80,
_ => 128,
}
}
fn quality(&self, _from: &str, to: &str) -> u8 {
match to {
"flac" | "wav" | "aiff" | "au" | "alac" | "ape" | "wv" | "tta" | "caf" | "w64"
| "truehd" | "thd" => 255,
"opus" | "ogg" | "aac" | "m4a" | "eac3" | "dts" => 220,
"mp3" | "ac3" | "mka" | "mp2" | "ra" => 200,
"wma" | "amr" | "spx" | "gsm" | "voc" => 150,
_ => 128,
}
}
fn speed(&self, _from: &str, to: &str) -> u8 {
match to {
"wav" | "aiff" | "au" | "caf" | "w64" | "voc" => 255,
"flac" | "alac" | "wv" | "mp2" | "ac3" | "dts" => 200,
"mp3" | "aac" | "m4a" | "ogg" | "mka" | "eac3" => 180,
"opus" | "wma" | "amr" | "ape" | "tta" | "truehd" | "thd" | "spx" | "gsm" | "ra" => 150,
_ => 128,
}
}
#[tracing::instrument(skip(self, input, temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
to: &str,
temp_dir: &Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("ffmpeg_audio starting conversion: {} -> {}", _from, to);
let mut temp_in = tempfile::Builder::new()
.suffix(&format!(".{}", _from))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp input file: {}", e);
e.to_string()
})?;
temp_in.write_all(input).map_err(|e| {
tracing::error!("Failed to write to temp input file: {}", e);
e.to_string()
})?;
let temp_out = tempfile::Builder::new()
.suffix(&format!(".{}", to))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp output file: {}", e);
e.to_string()
})?;
let temp_out_path = temp_out.into_temp_path();
let in_path = temp_in.path().to_path_buf();
tracing::trace!("Temp files created. In: {:?}, Out: {:?}", in_path, temp_out_path);
let mut raw_args = vec![];
match to {
"mp3" => raw_args.extend(vec!["-c:a", "libmp3lame", "-q:a", "2"]),
"ogg" => raw_args.extend(vec!["-c:a", "libvorbis", "-q:a", "4"]),
"aac" | "m4a" | "mka" => raw_args.extend(vec!["-c:a", "aac", "-b:a", "192k"]),
"opus" => raw_args.extend(vec!["-c:a", "libopus", "-b:a", "128k"]),
"mp2" => raw_args.extend(vec!["-c:a", "mp2", "-b:a", "192k"]),
"wma" => raw_args.extend(vec!["-c:a", "wmav2", "-b:a", "192k"]),
"flac" => raw_args.extend(vec!["-c:a", "flac"]),
"alac" => raw_args.extend(vec!["-c:a", "alac"]),
"ape" => raw_args.extend(vec!["-c:a", "ape"]),
"wv" => raw_args.extend(vec!["-c:a", "wavpack"]),
"tta" => raw_args.extend(vec!["-c:a", "tta"]),
"ac3" => raw_args.extend(vec!["-c:a", "ac3", "-b:a", "384k"]),
"eac3" => raw_args.extend(vec!["-c:a", "eac3", "-b:a", "640k"]),
"dts" => raw_args.extend(vec!["-c:a", "dca", "-strict", "-2", "-b:a", "1536k"]),
"truehd" | "thd" => raw_args.extend(vec!["-c:a", "truehd", "-strict", "-2"]),
"wav" | "aiff" | "au" | "caf" | "w64" => raw_args.extend(vec!["-c:a", "pcm_s16le"]),
"amr" => raw_args.extend(vec![
"-ar",
"8000",
"-c:a",
"libopencore_amrnb",
"-b:a",
"12.2k",
]),
"spx" => raw_args.extend(vec!["-c:a", "libspeex"]),
"gsm" => raw_args.extend(vec!["-ar", "8000", "-c:a", "libgsm"]),
_ => raw_args.extend(vec!["-c:a", "copy"]),
}
tracing::debug!("Built ffmpeg arguments: {:?}", raw_args);
tracing::trace!("Executing ffmpeg command...");
let output = std::process::Command::new("ffmpeg")
.arg("-y") // Overwrite output files without asking
.arg("-i")
.arg(&in_path)
.args(&raw_args)
.arg(&temp_out_path)
.output()
.map_err(|e| {
tracing::error!("Failed to execute ffmpeg: {}", e);
e.to_string()
})?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
tracing::error!("ffmpeg execution failed: {}", err);
return Err(format!("ffmpeg failed: {}", err));
}
tracing::debug!("FFmpeg execution succeeded. Reading output file...");
std::fs::read(&temp_out_path).map_err(|e| {
tracing::error!("Failed to read output file {:?}: {}", temp_out_path, e);
e.to_string()
})
}
}
-271
View File
@@ -1,271 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::io::Write;
use std::path::Path;
pub struct PluginImpl;
impl Plugin for PluginImpl {
fn name(&self) -> &'static str {
"ffmpeg_video"
}
fn from_formats(&self) -> Vec<&'static str> {
vec![
"mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif", "m4v", "3gp", "ts", "m2ts",
"vob", "mpg", "mpeg", "mxf", "ogv", "apng", "webp",
]
}
fn to_formats(&self) -> Vec<&'static str> {
vec![
"mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif", "m4v", "3gp", "ts", "m2ts",
"vob", "mpg", "mpeg", "mxf", "ogv", "apng", "webp",
]
}
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability of ffmpeg for ffmpeg_video plugin");
let available = std::process::Command::new("ffmpeg")
.arg("-version")
.output()
.is_ok();
tracing::debug!("ffmpeg_video plugin available: {}", available);
available
}
fn familiarity(&self, _from: &str, to: &str) -> u8 {
match to {
"mp4" | "gif" => 255,
"mov" | "m4v" => 240,
"webm" => 220,
"mkv" => 180,
"wmv" | "mpg" | "mpeg" => 160,
"flv" | "avi" | "3gp" | "vob" => 120,
"ts" | "m2ts" | "mxf" | "ogv" | "apng" | "webp" => 100,
_ => 128,
}
}
fn quality(&self, _from: &str, to: &str) -> u8 {
match to {
"mkv" | "mxf" | "ts" | "m2ts" => 250,
"mov" | "mp4" | "m4v" => 240,
"webm" => 210,
"wmv" | "mpg" | "mpeg" | "vob" => 200,
"flv" | "avi" | "ogv" | "3gp" => 150,
"gif" | "apng" => 100,
"webp" => 50,
_ => 200,
}
}
fn speed(&self, _from: &str, to: &str) -> u8 {
match to {
"mp4" | "mov" | "m4v" => 220,
"mkv" | "mpg" | "mpeg" | "ts" | "m2ts" | "mxf" | "vob" => 200,
"flv" | "avi" | "3gp" | "ogv" => 180,
"wmv" => 170,
"gif" | "apng" => 150,
"webp" => 100,
"webm" => 50,
_ => 150,
}
}
#[tracing::instrument(skip(self, input, temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
to: &str,
temp_dir: &Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("ffmpeg_video starting conversion: {} -> {}", _from, to);
let mut temp_in = tempfile::Builder::new()
.suffix(&format!(".{}", _from))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp input file: {}", e);
e.to_string()
})?;
temp_in.write_all(input).map_err(|e| {
tracing::error!("Failed to write to temp input file: {}", e);
e.to_string()
})?;
let temp_out = tempfile::Builder::new()
.suffix(&format!(".{}", to))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp output file: {}", e);
e.to_string()
})?;
let temp_out_path = temp_out.into_temp_path();
let in_path = temp_in.path().to_path_buf();
tracing::trace!(
"Temp files created. In: {:?}, Out: {:?}",
in_path,
temp_out_path
);
let mut raw_args = vec![];
match to {
"mp4" | "mkv" | "mov" | "m4v" => {
raw_args.extend(vec![
"-c:v", "libx264", "-crf", "23", "-c:a", "aac", "-pix_fmt", "yuv420p",
]);
}
"webm" => {
raw_args.extend(vec![
"-c:v",
"libvpx-vp9",
"-crf",
"30",
"-b:v",
"0",
"-c:a",
"libopus",
]);
}
"avi" => {
raw_args.extend(vec![
"-c:v",
"mpeg4",
"-vtag",
"xvid",
"-qscale:v",
"3",
"-c:a",
"libmp3lame",
]);
}
"wmv" => {
raw_args.extend(vec![
"-c:v", "wmv2", "-b:v", "1024k", "-c:a", "wmav2", "-b:a", "128k",
]);
}
"flv" => {
raw_args.extend(vec![
"-c:v",
"flv1",
"-b:v",
"800k",
"-c:a",
"libmp3lame",
"-b:a",
"128k",
]);
}
"ts" | "m2ts" | "mpg" | "mpeg" | "vob" => {
raw_args.extend(vec![
"-c:v",
"mpeg2video",
"-qscale:v",
"2",
"-c:a",
"mp2",
"-b:a",
"192k",
]);
}
"mxf" => {
raw_args.extend(vec![
"-c:v",
"mpeg2video",
"-qscale:v",
"2",
"-c:a",
"pcm_s16le",
]);
}
"ogv" => {
raw_args.extend(vec![
"-c:v",
"libtheora",
"-qscale:v",
"6",
"-c:a",
"libvorbis",
"-qscale:a",
"4",
]);
}
"3gp" => {
raw_args.extend(vec![
"-c:v",
"h263",
"-s",
"352x288",
"-c:a",
"libopencore_amrnb",
"-ar",
"8000",
]);
}
"gif" => {
raw_args.extend(vec!["-vf", "fps=15,scale=320:-1:flags=lanczos"]);
}
"webp" => {
raw_args.extend(vec![
"-c:v",
"libwebp",
"-lossless",
"0",
"-qscale",
"75",
"-loop",
"0",
]);
}
"apng" => {
raw_args.extend(vec!["-plays", "0"]);
}
_ => {
raw_args.extend(vec!["-qscale:v", "3"]);
}
}
tracing::debug!("Built ffmpeg arguments: {:?}", raw_args);
tracing::trace!("Executing ffmpeg command...");
let output = std::process::Command::new("ffmpeg")
.arg("-y") // Overwrite output files without asking
.arg("-i")
.arg(&in_path)
.args(&raw_args)
.arg(&temp_out_path)
.output()
.map_err(|e| {
tracing::error!("Failed to execute ffmpeg: {}", e);
e.to_string()
})?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr);
tracing::error!("ffmpeg execution failed: {}", err);
return Err(format!("ffmpeg failed: {}", err));
}
tracing::debug!("FFmpeg execution succeeded. Reading output file...");
std::fs::read(&temp_out_path).map_err(|e| {
tracing::error!("Failed to read output file {:?}: {}", temp_out_path, e);
e.to_string()
})
}
}
-160
View File
@@ -1,160 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::io::Write;
use std::path::Path;
pub struct PluginImpl;
impl Plugin for PluginImpl {
fn name(&self) -> &'static str {
"graphics_magick"
}
fn from_formats(&self) -> Vec<&'static str> {
vec![
"art", "avif", "bmp", "cmyk", "dpx", "eps", "fits", "gif", "gray", "heic", "ico",
"j2k", "jp2", "jpeg", "jpg", "jxl", "mat", "miff", "mono", "pam", "pbm", "pcx", "pdf",
"pgm", "pict", "png", "pnm", "ppm", "ps", "rgb", "rgba", "sgi", "sun", "svg", "tga",
"tiff", "viff", "webp", "wmf", "xbm", "xcf", "xpm", "xwd",
]
}
fn to_formats(&self) -> Vec<&'static str> {
vec![
"art", "avif", "bmp", "cmyk", "dpx", "eps", "fits", "gif", "gray", "heic", "ico",
"j2k", "jp2", "jpeg", "jpg", "jxl", "mat", "miff", "mono", "pam", "pbm", "pcx", "pdf",
"pgm", "pict", "png", "pnm", "ppm", "ps", "rgb", "rgba", "sgi", "sun", "svg", "tga",
"tiff", "viff", "webp", "wmf", "xbm", "xcf", "xpm", "xwd",
]
}
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability of gm for graphics_magick plugin");
let available = std::process::Command::new("gm")
.arg("-version")
.output()
.is_ok();
tracing::debug!("graphics_magick plugin available: {}", available);
if !available {
tracing::warn!(
"graphics_magick plugin not available. Please install GraphicsMagick to use it."
);
}
available
}
fn familiarity(&self, _from: &str, to: &str) -> u8 {
match to {
"png" | "jpeg" | "jpg" | "gif" => 255,
"bmp" | "tiff" | "ico" | "tga" | "webp" => 240,
"pdf" | "ps" | "eps" | "svg" => 200,
"avif" | "heic" | "jxl" | "jp2" | "j2k" => 180,
"pnm" | "ppm" | "pgm" | "pbm" | "pam" | "pcx" | "pict" | "dpx" | "miff" | "fits"
| "xcf" => 150,
_ => 128,
}
}
fn quality(&self, _from: &str, to: &str) -> u8 {
match to {
"png" | "bmp" | "tiff" | "ico" | "tga" | "pnm" | "ppm" | "pgm" | "pbm" | "pam" => 250,
"jpeg" | "jpg" | "webp" | "avif" | "heic" | "jxl" | "jp2" | "j2k" => 200,
"gif" => 150,
_ => 200,
}
}
fn speed(&self, _from: &str, to: &str) -> u8 {
match to {
_ => 255,
}
}
#[tracing::instrument(skip(self, input, temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
to: &str,
temp_dir: &Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("graphics_magick starting conversion: {} -> {}", _from, to);
let mut temp_in = tempfile::Builder::new()
.suffix(&format!(".{}", _from))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp input file: {}", e);
e.to_string()
})?;
temp_in.write_all(input).map_err(|e| {
tracing::error!("Failed to write to temp input file: {}", e);
e.to_string()
})?;
let temp_out = tempfile::Builder::new()
.suffix(&format!(".{}", to))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp output file: {}", e);
e.to_string()
})?;
let temp_out_path = temp_out.into_temp_path();
let in_path = temp_in.path().to_path_buf();
tracing::trace!(
"Temp files created. In: {:?}, Out: {:?}",
in_path,
temp_out_path
);
let raw_args = vec![
"convert",
in_path.to_str().unwrap(),
temp_out_path.to_str().unwrap(),
];
tracing::debug!("Built graphics_magick arguments: {:?}", raw_args);
let rt = tokio::runtime::Runtime::new().map_err(|e| {
tracing::error!("Failed to create Tokio runtime: {}", e);
e.to_string()
})?;
rt.block_on(async {
tracing::trace!("Executing GraphicsMagick...");
let output = std::process::Command::new("gm")
.args(raw_args)
.output()
.map_err(|e| {
tracing::error!("GraphicsMagick execution failed: {}", e);
e.to_string()
})?;
if !output.status.success() {
let err_msg = String::from_utf8_lossy(&output.stderr);
tracing::error!("GraphicsMagick failed with status {}. Stderr: {}", output.status, err_msg);
return Err(format!("GraphicsMagick error: {}", err_msg));
}
Ok(())
})?;
tracing::debug!("GraphicsMagick execution succeeded. Reading output file...");
std::fs::read(&temp_out_path).map_err(|e| {
tracing::error!("Failed to read output file {:?}: {}", temp_out_path, e);
e.to_string()
})
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "convertis-graphicsmagick"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
+60
View File
@@ -0,0 +1,60 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
use std::process::Command;
const FORMATS: &[&str] = &[
"png", "jpeg", "gif", "webp", "bmp", "tiff", "ico", "avif", "svg", "pdf",
];
struct GraphicsMagick;
impl Plugin for GraphicsMagick {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "graphicsmagick".into(),
package: "convertis-graphicsmagick".into(),
description: "GraphicsMagick image conversion".into(),
conversions: FORMATS
.iter()
.flat_map(|from| {
FORMATS
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, (210, 215, 180)))
})
.collect(),
options: vec![],
}
}
fn availability(&self) -> Result<(), String> {
Command::new("gm")
.arg("-version")
.output()
.map_err(|_| "'gm' is required".to_owned())
.map(|_| ())
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let output = Command::new("gm")
.arg("convert")
.arg(&request.input)
.arg(&request.output)
.output()
.map_err(|error| error.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).into_owned())
}
}
}
convertis_plugin_api::export_plugin!(GraphicsMagick, "graphicsmagick");
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "convertis-html"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
base64.workspace = true
convertis-plugin-api.workspace = true
[dev-dependencies]
tempfile.workspace = true
+178
View File
@@ -0,0 +1,178 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use base64::{Engine, engine::general_purpose::STANDARD};
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
const INPUTS: &[&str] = &[
"png",
"jpeg",
"gif",
"animated-gif",
"webp",
"animated-webp",
"apng",
"bmp",
"tiff",
"ico",
"avif",
"heic",
"jxl",
"svg",
"pdf",
"mp4",
"webm",
"mkv",
"avi",
"mov",
"mpeg",
"ogv",
"mp3",
"ogg",
"aac",
"m4a",
"opus",
"wma",
"wav",
"flac",
"aiff",
"au",
"text",
];
struct Html;
fn escape(value: &str) -> String {
value
.chars()
.map(|character| match character {
'&' => "&amp;".to_owned(),
'<' => "&lt;".to_owned(),
'>' => "&gt;".to_owned(),
'"' => "&quot;".to_owned(),
'\'' => "&#39;".to_owned(),
other => other.to_string(),
})
.collect()
}
fn mime(format: &str) -> &'static str {
match format {
"png" | "apng" => "image/png",
"jpeg" => "image/jpeg",
"gif" | "animated-gif" => "image/gif",
"webp" | "animated-webp" => "image/webp",
"bmp" => "image/bmp",
"tiff" => "image/tiff",
"ico" => "image/x-icon",
"avif" => "image/avif",
"heic" => "image/heic",
"jxl" => "image/jxl",
"svg" => "image/svg+xml",
"mp4" => "video/mp4",
"webm" => "video/webm",
"ogv" => "video/ogg",
"mp3" => "audio/mpeg",
"ogg" => "audio/ogg",
"wav" => "audio/wav",
"flac" => "audio/flac",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
impl Plugin for Html {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "html".into(),
package: "convertis-html".into(),
description: "Create self-contained HTML".into(),
conversions: INPUTS
.iter()
.map(|from| Conversion::file(from, "html", (255, 255, 255)))
.collect(),
options: vec![],
}
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let body = if request.from == "text" {
let text =
std::fs::read_to_string(&request.input).map_err(|error| error.to_string())?;
format!("<p style=\"white-space:pre-wrap\">{}</p>", escape(&text))
} else {
let data =
STANDARD.encode(std::fs::read(&request.input).map_err(|error| error.to_string())?);
let uri = format!("data:{};base64,{}", mime(&request.from), data);
if matches!(
request.from.as_str(),
"png"
| "apng"
| "jpeg"
| "gif"
| "animated-gif"
| "webp"
| "animated-webp"
| "bmp"
| "tiff"
| "ico"
| "avif"
| "heic"
| "jxl"
| "svg"
) {
format!("<img alt=\"Embedded media\" src=\"{uri}\">")
} else if matches!(
request.from.as_str(),
"mp3" | "ogg" | "aac" | "m4a" | "opus" | "wma" | "wav" | "flac" | "aiff" | "au"
) {
format!("<audio controls src=\"{uri}\"></audio>")
} else if request.from == "pdf" {
format!("<embed type=\"application/pdf\" src=\"{uri}\">")
} else {
format!("<video controls src=\"{uri}\"></video>")
}
};
let document = format!(
"<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width\"><title>Converted media</title></head><body>{body}</body></html>\n"
);
std::fs::write(&request.output, document).map_err(|error| error.to_string())
}
}
convertis_plugin_api::export_plugin!(Html, "html");
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn text_is_escaped_inside_a_paragraph() {
let directory = tempfile::tempdir().unwrap();
let input = directory.path().join("input.txt");
let output = directory.path().join("output.html");
std::fs::write(&input, "<script>alert('x')</script>").unwrap();
Html.convert(&ConversionRequest {
input,
output: output.clone(),
from: "text".into(),
to: "html".into(),
options: BTreeMap::new(),
})
.unwrap();
let html = std::fs::read_to_string(output).unwrap();
assert!(html.contains("&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;"));
assert!(!html.contains("<script>"));
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "convertis-image-ascii"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
image.workspace = true
+96
View File
@@ -0,0 +1,96 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, OptionSpec, Plugin, PluginMetadata};
const FORMATS: &[&str] = &["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"];
struct ImageAscii;
impl Plugin for ImageAscii {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "image-ascii".into(),
package: "convertis-image-ascii".into(),
description: "Render images as ASCII text".into(),
conversions: FORMATS
.iter()
.map(|from| Conversion::file(from, "text", (255, 180, 230)))
.collect(),
options: vec![
OptionSpec {
name: "width".into(),
help: "Output width in characters".into(),
default: Some("80".into()),
},
OptionSpec {
name: "characters".into(),
help: "Dark-to-light character ramp".into(),
default: Some("@%#*+=-:. ".into()),
},
OptionSpec {
name: "invert".into(),
help: "Reverse the character ramp".into(),
default: Some("false".into()),
},
],
}
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let image = image::open(&request.input).map_err(|error| error.to_string())?;
let width: u32 = request
.options
.get("width")
.map(String::as_str)
.unwrap_or("80")
.parse()
.map_err(|_| "width must be a positive integer")?;
if width == 0 {
return Err("width must be greater than zero".into());
}
let height = ((image.height() as f32 / image.width() as f32) * width as f32 * 0.5)
.round()
.max(1.0) as u32;
let grayscale = image
.resize_exact(width, height, image::imageops::FilterType::Triangle)
.to_luma8();
let mut ramp: Vec<char> = request
.options
.get("characters")
.map(String::as_str)
.unwrap_or("@%#*+=-:. ")
.chars()
.collect();
if ramp.len() < 2 {
return Err("characters must contain at least two characters".into());
}
if request
.options
.get("invert")
.is_some_and(|value| value == "true")
{
ramp.reverse();
}
let mut output = String::with_capacity((width as usize + 1) * height as usize);
for y in 0..height {
for x in 0..width {
let value = grayscale.get_pixel(x, y)[0] as usize;
output.push(ramp[value * (ramp.len() - 1) / 255]);
}
output.push('\n');
}
std::fs::write(&request.output, output).map_err(|error| error.to_string())
}
}
convertis_plugin_api::export_plugin!(ImageAscii, "image-ascii");
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "convertis-imagemagick"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
+59
View File
@@ -0,0 +1,59 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
use std::process::Command;
const FORMATS: &[&str] = &[
"png", "jpeg", "gif", "webp", "bmp", "tiff", "ico", "avif", "heic", "jxl", "svg", "pdf",
];
struct ImageMagick;
impl Plugin for ImageMagick {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "imagemagick".into(),
package: "convertis-imagemagick".into(),
description: "ImageMagick image conversion".into(),
conversions: FORMATS
.iter()
.flat_map(|from| {
FORMATS
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, (220, 220, 170)))
})
.collect(),
options: vec![],
}
}
fn availability(&self) -> Result<(), String> {
Command::new("magick")
.arg("-version")
.output()
.map_err(|_| "'magick' is required".to_owned())
.map(|_| ())
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let output = Command::new("magick")
.arg(&request.input)
.arg(&request.output)
.output()
.map_err(|error| error.to_string())?;
if output.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&output.stderr).into_owned())
}
}
}
convertis_plugin_api::export_plugin!(ImageMagick, "imagemagick");
-78
View File
@@ -1,78 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use image::ImageFormat;
use std::io::Cursor;
use std::path::Path;
pub struct PluginImpl;
impl Plugin for PluginImpl {
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability for jpeg_to_png plugin (always true)");
true
}
fn name(&self) -> &'static str {
"jpeg_to_png"
}
fn from_formats(&self) -> Vec<&'static str> {
vec!["jpeg"]
}
fn to_formats(&self) -> Vec<&'static str> {
vec!["png"]
}
fn familiarity(&self, _from: &str, _to: &str) -> u8 {
255
}
fn quality(&self, _from: &str, _to: &str) -> u8 {
255
}
fn speed(&self, _from: &str, _to: &str) -> u8 {
200
}
#[tracing::instrument(skip(self, input, _temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
_to: &str,
_temp_dir: &Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("jpeg_to_png starting conversion");
tracing::trace!("Loading JPEG image from memory ({} bytes)", input.len());
let img = image::load_from_memory_with_format(input, ImageFormat::Jpeg)
.map_err(|e| {
tracing::error!("Failed to decode JPEG image: {}", e);
e.to_string()
})?;
tracing::trace!("JPEG image loaded successfully. Dimensions: {}x{}", img.width(), img.height());
let mut output = Cursor::new(Vec::new());
tracing::trace!("Encoding image as PNG");
img.write_to(&mut output, ImageFormat::Png)
.map_err(|e| {
tracing::error!("Failed to encode image as PNG: {}", e);
e.to_string()
})?;
let out_bytes = output.into_inner();
tracing::debug!("jpeg_to_png conversion completed. Output size: {} bytes", out_bytes.len());
Ok(out_bytes)
}
}
-157
View File
@@ -1,157 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::io::Write;
use std::path::Path;
pub struct PluginImpl;
impl Plugin for PluginImpl {
fn name(&self) -> &'static str {
"magick"
}
fn from_formats(&self) -> Vec<&'static str> {
vec![
"art", "avif", "bmp", "cmyk", "dpx", "eps", "fits", "gif", "gray", "heic", "ico",
"j2k", "jp2", "jpeg", "jpg", "jxl", "mat", "miff", "mono", "pam", "pbm", "pcx", "pdf",
"pgm", "pict", "png", "pnm", "ppm", "ps", "rgb", "rgba", "sgi", "sun", "svg", "tga",
"tiff", "viff", "webp", "wmf", "xbm", "xcf", "xpm", "xwd",
]
}
fn to_formats(&self) -> Vec<&'static str> {
vec![
"art", "avif", "bmp", "cmyk", "dpx", "eps", "fits", "gif", "gray", "heic", "ico",
"j2k", "jp2", "jpeg", "jpg", "jxl", "mat", "miff", "mono", "pam", "pbm", "pcx", "pdf",
"pgm", "pict", "png", "pnm", "ppm", "ps", "rgb", "rgba", "sgi", "sun", "svg", "tga",
"tiff", "viff", "webp", "wmf", "xbm", "xcf", "xpm", "xwd",
]
}
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability of magick for magick plugin");
let available = std::process::Command::new("magick")
.arg("-version")
.output()
.is_ok();
tracing::debug!("magick plugin available: {}", available);
if !available {
tracing::warn!("magick plugin not available. Please install ImageMagick to use it.");
}
available
}
fn familiarity(&self, _from: &str, to: &str) -> u8 {
match to {
"png" | "jpeg" | "jpg" | "gif" => 255,
"bmp" | "tiff" | "ico" | "tga" | "webp" => 240,
"pdf" | "ps" | "eps" | "svg" => 200,
"avif" | "heic" | "jxl" | "jp2" | "j2k" => 180,
"pnm" | "ppm" | "pgm" | "pbm" | "pam" | "pcx" | "pict" | "dpx" | "miff" | "fits"
| "xcf" => 150,
_ => 128,
}
}
fn quality(&self, _from: &str, to: &str) -> u8 {
match to {
"png" | "bmp" | "tiff" | "ico" | "tga" | "pnm" | "ppm" | "pgm" | "pbm" | "pam" => 250,
"jpeg" | "jpg" | "webp" | "avif" | "heic" | "jxl" | "jp2" | "j2k" => 200,
"gif" => 150,
_ => 200,
}
}
fn speed(&self, _from: &str, to: &str) -> u8 {
match to {
_ => 128,
}
}
#[tracing::instrument(skip(self, input, temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
to: &str,
temp_dir: &Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("magick starting conversion: {} -> {}", _from, to);
let mut temp_in = tempfile::Builder::new()
.suffix(&format!(".{}", _from))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp input file: {}", e);
e.to_string()
})?;
temp_in.write_all(input).map_err(|e| {
tracing::error!("Failed to write to temp input file: {}", e);
e.to_string()
})?;
let temp_out = tempfile::Builder::new()
.suffix(&format!(".{}", to))
.tempfile_in(temp_dir)
.map_err(|e| {
tracing::error!("Failed to create temp output file: {}", e);
e.to_string()
})?;
let temp_out_path = temp_out.into_temp_path();
let in_path = temp_in.path().to_path_buf();
tracing::trace!(
"Temp files created. In: {:?}, Out: {:?}",
in_path,
temp_out_path
);
let raw_args = vec![
in_path.to_str().unwrap(),
temp_out_path.to_str().unwrap(),
];
tracing::debug!("Built magick arguments: {:?}", raw_args);
let rt = tokio::runtime::Runtime::new().map_err(|e| {
tracing::error!("Failed to create Tokio runtime: {}", e);
e.to_string()
})?;
rt.block_on(async {
tracing::trace!("Executing ImageMagick...");
let output = std::process::Command::new("magick")
.args(raw_args)
.output()
.map_err(|e| {
tracing::error!("ImageMagick execution failed: {}", e);
e.to_string()
})?;
if !output.status.success() {
let err_msg = String::from_utf8_lossy(&output.stderr);
tracing::error!("ImageMagick failed with status {}. Stderr: {}", output.status, err_msg);
return Err(format!("ImageMagick error: {}", err_msg));
}
Ok(())
})?;
tracing::debug!("ImageMagick execution succeeded. Reading output file...");
std::fs::read(&temp_out_path).map_err(|e| {
tracing::error!("Failed to read output file {:?}: {}", temp_out_path, e);
e.to_string()
})
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "convertis-native-image"
build = "../../plugin-build.rs"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
convertis-plugin-api.workspace = true
image.workspace = true
+61
View File
@@ -0,0 +1,61 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{Conversion, ConversionRequest, Plugin, PluginMetadata};
use image::{ImageFormat, ImageReader};
use std::{fs::File, io::BufReader};
const FORMATS: &[&str] = &["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"];
struct NativeImage;
fn image_format(name: &str) -> Result<ImageFormat, String> {
ImageFormat::from_extension(name).ok_or_else(|| format!("unsupported image format '{name}'"))
}
impl Plugin for NativeImage {
fn metadata(&self) -> PluginMetadata {
PluginMetadata {
id: "native-image".into(),
package: "convertis-native-image".into(),
description: "Native common image conversion".into(),
conversions: FORMATS
.iter()
.flat_map(|from| {
FORMATS
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, (255, 230, 240)))
})
.collect(),
options: vec![],
}
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
let reader = ImageReader::with_format(
BufReader::new(File::open(&request.input).map_err(|error| error.to_string())?),
image_format(&request.from)?,
);
let mut image = reader.decode().map_err(|error| error.to_string())?;
if request.to == "ico" && (image.width() > 256 || image.height() > 256) {
image = image.resize(256, 256, image::imageops::FilterType::Lanczos3);
}
image
.save_with_format(&request.output, image_format(&request.to)?)
.map_err(|error| error.to_string())
}
}
convertis_plugin_api::export_plugin!(NativeImage, "native-image");
-74
View File
@@ -1,74 +0,0 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
pub struct PluginImpl;
impl Plugin for PluginImpl {
#[tracing::instrument(skip(self))]
fn is_available(&self) -> bool {
tracing::trace!("Checking availability for png_to_jpeg plugin (always true)");
true
}
fn name(&self) -> &'static str {
"png_to_jpeg"
}
fn from_formats(&self) -> Vec<&'static str> {
vec!["png"]
}
fn to_formats(&self) -> Vec<&'static str> {
vec!["jpeg"]
}
fn familiarity(&self, _from: &str, _to: &str) -> u8 {
255
}
fn quality(&self, _from: &str, _to: &str) -> u8 {
200
} // JPEG has some compression loss
fn speed(&self, _from: &str, _to: &str) -> u8 {
220
}
#[tracing::instrument(skip(self, input, _temp_dir))]
fn convert(
&self,
input: &[u8],
_from: &str,
_to: &str,
_temp_dir: &std::path::Path,
) -> Result<Vec<u8>, String> {
tracing::debug!("png_to_jpeg starting conversion");
tracing::trace!("Loading PNG image from memory ({} bytes)", input.len());
let img = image::load_from_memory_with_format(input, image::ImageFormat::Png)
.map_err(|e| {
tracing::error!("Failed to decode PNG image: {}", e);
e.to_string()
})?;
tracing::trace!("PNG image loaded successfully. Dimensions: {}x{}", img.width(), img.height());
let mut output = std::io::Cursor::new(Vec::new());
tracing::trace!("Encoding image as JPEG");
img.write_to(&mut output, image::ImageFormat::Jpeg)
.map_err(|e| {
tracing::error!("Failed to encode image as JPEG: {}", e);
e.to_string()
})?;
let out_bytes = output.into_inner();
tracing::debug!("png_to_jpeg conversion completed. Output size: {} bytes", out_bytes.len());
Ok(out_bytes)
}
}
+36 -51
View File
@@ -16,79 +16,65 @@ use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(about = "A file converter program.", long_about = None)]
#[command(about = "A modular file converter.", long_about = None)]
pub struct Args {
#[arg(
long,
help = "List all supported formats.",
help = "List formats supported by installed plugins.",
exclusive = true
)]
pub list_formats: bool,
#[arg(
short = 'v',
long,
default_value = "warn",
help = "Output information about the conversion (trace, debug, info, warn, error)"
help = "List installed and known official plugins.",
exclusive = true
)]
pub list_plugins: bool,
#[arg(short = 'v', long, default_value = "warn")]
pub verbose: Verbosity,
#[arg(
short,
long,
help = "Test the conversion process without actually converting the file."
)]
#[arg(short, long, help = "Show the selected route without converting.")]
pub test: bool,
#[arg(
short,
long,
help = "Output nothing to the console, silently fail on errors."
)]
#[arg(short, long)]
pub quiet: bool,
#[arg(
short = 'y',
long,
help = "Overwrite output files without asking.",
conflicts_with = "no"
)]
#[arg(short = 'y', long, conflicts_with = "no")]
pub yes: bool,
#[arg(
short = 'n',
long,
help = "Do not overwrite output files, exit immediately if file exists.",
conflicts_with = "yes"
)]
#[arg(short = 'n', long, conflicts_with = "yes")]
pub no: bool,
#[arg(
short = 'c',
long,
help = "Get rid of the warning message when not piping the file."
)]
#[arg(short = 'c', long)]
pub write_to_console: bool,
#[arg(
short,
long,
default_value = "fqs",
help = "Set the priority of the conversion process (e.g. -p fqs means \"familiarity, speed, quality\")."
)]
#[arg(short, long, default_value = "fqs")]
pub priority: String,
#[arg(help = "The input file path.")]
pub input_path: Option<String>,
#[arg(long = "from", help = "Override content-based input detection.")]
pub from_format: Option<String>,
#[arg(help = "The output file path (optional).")]
pub output_path: Option<String>,
#[arg(long = "to", help = "Override output-extension target selection.")]
pub to_format: Option<String>,
#[arg(
short = 'T',
long,
default_value = "/dev/shm",
help = "Directory to use for temporary files during conversion. Defaults to /dev/shm (RAM). Use a disk path for very large files."
)]
#[arg(long = "option", value_name = "KEY=VALUE", action = clap::ArgAction::Append)]
pub options: Vec<String>,
#[arg(long = "plugin-dir", value_name = "DIRECTORY", action = clap::ArgAction::Append)]
pub plugin_dirs: Vec<PathBuf>,
#[arg(long, help = "Load only explicitly configured plugin directories.")]
pub no_default_plugins: bool,
#[arg(help = "Input file or directory.")]
pub input_path: Option<PathBuf>,
#[arg(help = "Output file or directory.")]
pub output_path: Option<PathBuf>,
#[arg(short = 'T', long, default_value = "/dev/shm")]
pub temp_dir: PathBuf,
}
@@ -102,8 +88,8 @@ pub enum Verbosity {
}
impl From<Verbosity> for tracing::Level {
fn from(v: Verbosity) -> Self {
match v {
fn from(value: Verbosity) -> Self {
match value {
Verbosity::Trace => tracing::Level::TRACE,
Verbosity::Debug => tracing::Level::DEBUG,
Verbosity::Info => tracing::Level::INFO,
@@ -112,4 +98,3 @@ impl From<Verbosity> for tracing::Level {
}
}
}
+253
View File
@@ -0,0 +1,253 @@
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use convertis_plugin_api::{ArtifactKind, Conversion, OptionSpec, PluginMetadata};
use std::collections::{HashMap, HashSet, VecDeque};
const AUDIO: &[&str] = &[
"mp3", "ogg", "aac", "m4a", "opus", "wma", "wav", "flac", "aiff", "au",
];
const VIDEO_INPUTS: &[&str] = &[
"mp4",
"webm",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"gif",
"animated-gif",
"m4v",
"mpeg",
"ogv",
"apng",
"webp",
"animated-webp",
];
const VIDEO_OUTPUTS: &[&str] = &[
"mp4",
"webm",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"animated-gif",
"m4v",
"mpeg",
"ogv",
"apng",
"animated-webp",
];
const IMAGES: &[&str] = &["png", "jpeg", "gif", "webp", "bmp", "tiff", "ico"];
const MAGICK_IMAGES: &[&str] = &[
"png", "jpeg", "gif", "webp", "bmp", "tiff", "ico", "avif", "heic", "jxl", "svg", "pdf",
];
fn all_pairs(formats: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
formats
.iter()
.flat_map(|from| {
formats
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, scores))
})
.collect()
}
fn cross_pairs(inputs: &[&str], outputs: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
inputs
.iter()
.flat_map(|from| {
outputs
.iter()
.filter(move |to| to != &from)
.map(move |to| Conversion::file(from, to, scores))
})
.collect()
}
fn metadata(
id: &str,
package: &str,
description: &str,
conversions: Vec<Conversion>,
) -> PluginMetadata {
PluginMetadata {
id: id.to_owned(),
package: package.to_owned(),
description: description.to_owned(),
conversions,
options: Vec::new(),
}
}
pub fn official_plugins() -> Vec<PluginMetadata> {
let mut plugins = vec![
metadata(
"ffmpeg-audio",
"convertis-ffmpeg-audio",
"FFmpeg audio conversion",
all_pairs(AUDIO, (240, 220, 190)),
),
metadata(
"ffmpeg-video",
"convertis-ffmpeg-video",
"FFmpeg video conversion",
cross_pairs(VIDEO_INPUTS, VIDEO_OUTPUTS, (240, 210, 180)),
),
metadata(
"native-image",
"convertis-native-image",
"Native common image conversion",
all_pairs(IMAGES, (255, 230, 240)),
),
metadata(
"imagemagick",
"convertis-imagemagick",
"ImageMagick conversion",
all_pairs(MAGICK_IMAGES, (220, 220, 170)),
),
metadata(
"graphicsmagick",
"convertis-graphicsmagick",
"GraphicsMagick conversion",
all_pairs(MAGICK_IMAGES, (210, 215, 180)),
),
metadata(
"html",
"convertis-html",
"Self-contained HTML generation",
AUDIO
.iter()
.chain(VIDEO_INPUTS)
.chain(MAGICK_IMAGES)
.chain([&"text"])
.map(|from| Conversion::file(from, "html", (255, 255, 255)))
.collect(),
),
metadata(
"image-ascii",
"convertis-image-ascii",
"Image to ASCII text",
IMAGES
.iter()
.map(|from| Conversion::file(from, "text", (255, 180, 230)))
.collect(),
),
];
let mut to_frames: Vec<_> = VIDEO_INPUTS
.iter()
.map(|from| {
let mut conversion = Conversion::file(from, "frames", (255, 230, 190));
conversion.output_kind = ArtifactKind::Directory;
conversion
})
.collect();
plugins.push(metadata(
"ffmpeg-video-to-frames",
"convertis-ffmpeg-video-to-frames",
"Extract video frames",
std::mem::take(&mut to_frames),
));
plugins.last_mut().unwrap().options = vec![
OptionSpec {
name: "frame_format".into(),
help: "png, jpeg, or webp".into(),
default: Some("png".into()),
},
OptionSpec {
name: "fps".into(),
help: "Optional extraction frame rate".into(),
default: None,
},
];
let mut from_frames: Vec<_> = VIDEO_OUTPUTS
.iter()
.map(|to| {
let mut conversion = Conversion::file("frames", to, (255, 220, 180));
conversion.input_kind = ArtifactKind::Directory;
conversion
})
.collect();
plugins.push(metadata(
"ffmpeg-frames-to-video",
"convertis-ffmpeg-frames-to-video",
"Build video from frames",
std::mem::take(&mut from_frames),
));
plugins.last_mut().unwrap().options = vec![OptionSpec {
name: "fps".into(),
help: "Override frame rate; folders without metadata default to 30".into(),
default: None,
}];
plugins
}
pub fn recommend_packages(from: &str, to: &str) -> Vec<String> {
let plugins = official_plugins();
let mut edges: HashMap<&str, Vec<(usize, &str)>> = HashMap::new();
for (index, plugin) in plugins.iter().enumerate() {
for conversion in &plugin.conversions {
edges
.entry(&conversion.from)
.or_default()
.push((index, &conversion.to));
}
}
let mut queue = VecDeque::from([(from, Vec::<usize>::new())]);
let mut visited = HashSet::from([from]);
while let Some((current, path)) = queue.pop_front() {
if current == to {
let mut packages = Vec::new();
for index in path {
let package = plugins[index].package.clone();
if !packages.contains(&package) {
packages.push(package);
}
}
return packages;
}
if let Some(next_edges) = edges.get(current) {
for &(plugin, next) in next_edges {
if visited.insert(next) {
let mut next_path = path.clone();
next_path.push(plugin);
queue.push_back((next, next_path));
}
}
}
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recommends_a_single_direct_plugin() {
assert_eq!(
recommend_packages("png", "jpeg"),
vec!["convertis-native-image"]
);
assert_eq!(
recommend_packages("mp4", "frames"),
vec!["convertis-ffmpeg-video-to-frames"]
);
}
}
+229 -92
View File
@@ -12,115 +12,252 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::path::Path;
use convertis_plugin_api::{ArtifactKind, MediaKind};
use std::{fs, path::Path};
#[tracing::instrument(skip(input, plugins))]
pub fn identify_format(path: &str, input: &[u8], plugins: &[Box<dyn Plugin>]) -> Option<&'static str> {
tracing::trace!("Identifying format for path: {}", path);
let mut ext_str_opt = None;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DetectedFormat {
pub format: String,
pub mime: String,
pub media_kind: MediaKind,
pub artifact_kind: ArtifactKind,
}
if let Some(kind) = infer::get(input) {
tracing::trace!("infer detected file type: {:?}", kind.mime_type());
ext_str_opt = Some(kind.extension());
} else if !input.is_empty() {
tracing::trace!("infer failed to detect file type from magic bytes.");
fn normalized(format: &str) -> String {
match format.to_ascii_lowercase().as_str() {
"jpg" => "jpeg".to_owned(),
"htm" => "html".to_owned(),
"txt" | "ascii" => "text".to_owned(),
other => other.to_owned(),
}
}
let p = Path::new(path);
let mut ext = p
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase());
pub fn requested_format(path: Option<&Path>, explicit: Option<&str>) -> Option<String> {
explicit.map(normalized).or_else(|| {
path.and_then(Path::extension)
.and_then(|value| value.to_str())
.map(normalized)
})
}
if ext.is_none() {
ext = Some(path.to_lowercase());
}
let mut ext_str = ext.as_deref().unwrap_or("");
if let Some(magic_ext) = ext_str_opt {
tracing::debug!("Using magic byte extension over path extension: {} -> {}", ext_str, magic_ext);
ext_str = magic_ext;
} else {
tracing::debug!("Using path extension: {}", ext_str);
}
let ext_str = match ext_str {
"jpg" => "jpeg",
other => other,
};
tracing::trace!("Normalized extension to check plugins against: {}", ext_str);
for plugin in plugins {
tracing::trace!("Checking plugin {} formats for support of {}", plugin.name(), ext_str);
if let Some(&f) = plugin.from_formats().iter().find(|&&f| f == ext_str) {
tracing::debug!("Plugin {} supports {} as input.", plugin.name(), f);
return Some(f);
pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedFormat, String> {
if path.is_dir() {
if explicit.is_some_and(|format| normalized(format) != "frames") {
return Err("directory inputs currently support only the 'frames' format".to_owned());
}
if let Some(&f) = plugin.to_formats().iter().find(|&&f| f == ext_str) {
tracing::debug!("Plugin {} supports {} as output.", plugin.name(), f);
return Some(f);
let manifest = path.join(".convertis-frames.json");
if manifest.exists() {
let value: serde_json::Value = serde_json::from_slice(
&fs::read(&manifest)
.map_err(|error| format!("could not read {}: {error}", manifest.display()))?,
)
.map_err(|error| format!("invalid frame manifest: {error}"))?;
if value
.get("schema_version")
.and_then(serde_json::Value::as_u64)
!= Some(1)
{
return Err("unsupported frame manifest schema".to_owned());
}
} else {
let mut detected = None;
for entry in fs::read_dir(path)
.map_err(|error| error.to_string())?
.flatten()
{
if !entry.path().is_file() {
continue;
}
let bytes = fs::read(entry.path()).map_err(|error| error.to_string())?;
let Some(format) = identify_bytes(&bytes) else {
continue;
};
if format.media_kind != MediaKind::Image {
continue;
}
if detected
.as_ref()
.is_some_and(|current| current != &format.format)
{
return Err(
"frame directories without metadata must use one image format".to_owned(),
);
}
detected = Some(format.format);
}
if detected.is_none() {
return Err(
"directory has no frame manifest or recognizable image frames".to_owned(),
);
}
}
return Ok(DetectedFormat {
format: "frames".to_owned(),
mime: "application/vnd.convertis.frames+json".to_owned(),
media_kind: MediaKind::Frames,
artifact_kind: ArtifactKind::Directory,
});
}
let bytes =
fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?;
if let Some(format) = explicit {
return Ok(from_format(&normalized(format), &bytes));
}
identify_bytes(&bytes)
.ok_or_else(|| format!("could not identify {} from its contents", path.display()))
}
pub fn identify_bytes(bytes: &[u8]) -> Option<DetectedFormat> {
if bytes.starts_with(&[0, 0, 1, 0]) {
return Some(from_format("ico", bytes));
}
if bytes.starts_with(b"<!DOCTYPE html") || bytes.starts_with(b"<html") {
return Some(from_format("html", bytes));
}
if bytes.windows(4).any(|window| window == b"M4A ") {
return Some(from_format("m4a", bytes));
}
if bytes.starts_with(b"OggS") {
if bytes.windows(8).any(|window| window == b"OpusHead") {
return Some(from_format("opus", bytes));
}
if bytes.windows(6).any(|window| window == b"theora") {
return Some(from_format("ogv", bytes));
}
return Some(from_format("ogg", bytes));
}
if let Some(kind) = infer::get(bytes) {
return Some(from_format(&normalized(kind.extension()), bytes));
}
let text = std::str::from_utf8(bytes).ok()?;
if !text.contains('\0') {
return Some(from_format("text", bytes));
}
tracing::debug!("No plugin supports the format '{}'.", ext_str);
None
}
fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
let animation = match format {
"webp" => bytes.len() > 20 && &bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0,
"png" | "apng" => bytes.windows(4).any(|window| window == b"acTL"),
"gif" => gif_is_animated(bytes),
_ => false,
};
let media_kind = if animation {
MediaKind::Animation
} else if matches!(
format,
"png" | "jpeg" | "gif" | "webp" | "bmp" | "tiff" | "ico" | "avif" | "heic" | "jxl" | "svg"
) {
MediaKind::Image
} else if matches!(
format,
"mp3" | "wav" | "flac" | "aac" | "m4a" | "opus" | "ogg" | "wma" | "aiff" | "au"
) {
MediaKind::Audio
} else if matches!(
format,
"mp4" | "webm" | "mkv" | "avi" | "mov" | "wmv" | "flv" | "m4v" | "mpeg" | "ogv"
) {
MediaKind::Video
} else if format == "text" {
MediaKind::Text
} else if format == "html" {
MediaKind::Document
} else {
MediaKind::Unknown
};
let mime = match format {
"jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"tiff" => "image/tiff",
"ico" => "image/x-icon",
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"flac" => "audio/flac",
"mp4" => "video/mp4",
"webm" => "video/webm",
"text" => "text/plain; charset=utf-8",
"html" => "text/html; charset=utf-8",
_ => "application/octet-stream",
};
let format = if animation {
match format {
"png" | "apng" => "apng".to_owned(),
"gif" => "animated-gif".to_owned(),
"webp" => "animated-webp".to_owned(),
other => other.to_owned(),
}
} else {
format.to_owned()
};
DetectedFormat {
format,
mime: mime.to_owned(),
media_kind,
artifact_kind: ArtifactKind::File,
}
}
fn gif_is_animated(bytes: &[u8]) -> bool {
let mut options = gif::DecodeOptions::new();
options.set_color_output(gif::ColorOutput::Indexed);
let Ok(mut decoder) = options.read_info(std::io::Cursor::new(bytes)) else {
return false;
};
let mut frames = 0;
while let Ok(Some(_)) = decoder.read_next_frame() {
frames += 1;
if frames > 1 {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
struct MockPlugin;
impl Plugin for MockPlugin {
fn name(&self) -> &'static str { "mock" }
fn from_formats(&self) -> Vec<&'static str> {
vec!["jpeg", "png", "mp4", "webm", "mkv", "avi", "mov", "wmv", "flv", "gif", "mp3", "wav", "ogg", "flac", "aac", "m4a", "opus", "wma", "amr", "aiff", "au"]
}
fn to_formats(&self) -> Vec<&'static str> { vec![] }
fn familiarity(&self, _: &str, _: &str) -> u8 { 0 }
fn quality(&self, _: &str, _: &str) -> u8 { 0 }
fn speed(&self, _: &str, _: &str) -> u8 { 0 }
fn convert(&self, _: &[u8], _: &str, _: &str, _: &Path) -> Result<Vec<u8>, String> { Ok(vec![]) }
#[test]
fn output_extension_is_only_target_intent() {
assert_eq!(
requested_format(Some(Path::new("output.JPG")), None),
Some("jpeg".into())
);
assert_eq!(
requested_format(Some(Path::new("output.jpg")), Some("png")),
Some("png".into())
);
}
macro_rules! test_ident {
($name:ident, $ext:expr, $expected:expr) => {
#[test]
fn $name() {
let mock = Box::new(MockPlugin);
let plugins: Vec<Box<dyn Plugin>> = vec![mock];
assert_eq!(identify_format($ext, &[], &plugins), $expected);
}
};
#[test]
fn identifies_text_without_an_extension() {
assert_eq!(identify_bytes(b"hello\nworld").unwrap().format, "text");
}
test_ident!(test_identify_format_1, "test.jpg", Some("jpeg"));
test_ident!(test_identify_format_2, "test.jpeg", Some("jpeg"));
test_ident!(test_identify_format_3, "test.png", Some("png"));
test_ident!(test_identify_format_4, "png", Some("png"));
test_ident!(test_identify_format_5, "jpeg", Some("jpeg"));
test_ident!(test_identify_format_6, "test.txt", None);
test_ident!(test_identify_format_7, "FILE.JPG", Some("jpeg"));
test_ident!(test_identify_format_8, "file.PnG", Some("png"));
test_ident!(test_identify_format_9, "no_ext", None);
test_ident!(test_identify_format_10, "test.bmp", None);
test_ident!(test_identify_format_11, "file.JPEG", Some("jpeg"));
test_ident!(test_identify_format_12, "file.PNG", Some("png"));
test_ident!(
test_identify_format_13,
"complex.file.name.jpg",
Some("jpeg")
);
test_ident!(test_identify_format_14, ".hidden.png", Some("png"));
test_ident!(test_identify_format_15, "jpg", Some("jpeg")); // "jpg" passed without dot, treats as ext if no dot in path but path is "jpg", ext becomes "jpg"
test_ident!(test_identify_format_16, "a.jpg.txt", None);
test_ident!(test_identify_format_17, "a.png.bak", None);
test_ident!(test_identify_format_18, "a.b.c.JPEG", Some("jpeg"));
test_ident!(test_identify_format_19, "test_file_without_extension", None);
test_ident!(test_identify_format_20, ".jpg", None);
test_ident!(test_identify_format_21, "", None);
test_ident!(test_identify_format_22, "test.mp3", Some("mp3"));
test_ident!(test_identify_format_23, "test.WAV", Some("wav"));
test_ident!(test_identify_format_24, "audio.flac", Some("flac"));
test_ident!(test_identify_format_25, "music.ogg", Some("ogg"));
#[test]
fn identifies_ico_magic() {
assert_eq!(identify_bytes(&[0, 0, 1, 0, 1, 0]).unwrap().format, "ico");
}
#[test]
fn content_wins_over_a_misleading_name() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("actually-an-image.txt");
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
png.resize(32, 0);
std::fs::write(&path, png).unwrap();
assert_eq!(identify_path(&path, None).unwrap().format, "png");
}
#[test]
fn distinguishes_animated_webp() {
let mut webp = b"RIFF\x16\0\0\0WEBPVP8X\x0a\0\0\0\x02".to_vec();
webp.resize(32, 0);
assert_eq!(identify_bytes(&webp).unwrap().format, "animated-webp");
}
}
+199 -160
View File
@@ -12,198 +12,237 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
pub mod args;
pub mod identifier;
pub mod pathfinder;
pub mod plugin;
pub mod runner;
include!(concat!(env!("OUT_DIR"), "/plugins_gen.rs"));
mod args;
mod catalog;
mod identifier;
mod pathfinder;
mod plugin;
mod runner;
use args::Args;
use clap::{CommandFactory, FromArgMatches};
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::process;
use convertis_plugin_api::ArtifactKind;
use std::{
collections::{BTreeMap, HashSet},
fs,
io::{self, IsTerminal, Write},
path::Path,
process,
};
pub const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
fn should_overwrite(args: &Args, out_path: &str) -> bool {
match (args.yes, args.no || args.quiet) {
(true, _) => true,
(_, true) => false,
(false, false) => {
print!("File '{}' already exists. Overwrite? [y/N]: ", out_path);
let _ = io::stdout().flush();
let mut input = String::new();
match io::stdin().read_line(&mut input) {
Ok(_) => {
let input = input.trim().to_lowercase();
input == "y" || input == "yes"
}
Err(_) => false,
}
}
}
fn fail(message: impl std::fmt::Display) -> ! {
tracing::error!("{message}");
process::exit(1)
}
#[tracing::instrument]
fn main() {
let mut command = Args::command();
command = command.version(VERSION);
let matches = command.get_matches();
let args = Args::from_arg_matches(&matches).expect("Failed to parse arguments");
fn parse_options(values: &[String]) -> Result<BTreeMap<String, String>, String> {
let mut options = BTreeMap::new();
for value in values {
let (key, value) = value
.split_once('=')
.ok_or_else(|| format!("invalid option '{value}'; expected KEY=VALUE"))?;
if key.is_empty() || options.insert(key.to_owned(), value.to_owned()).is_some() {
return Err(format!("invalid or duplicate option '{key}'"));
}
}
Ok(options)
}
let level_filter = if args.quiet {
fn should_overwrite(args: &Args, output: &Path) -> bool {
if !output.exists() {
return true;
}
if args.yes {
return true;
}
if args.no || args.quiet || !io::stdin().is_terminal() {
return false;
}
print!("'{}' exists. Replace it? [y/N]: ", output.display());
let _ = io::stdout().flush();
let mut answer = String::new();
io::stdin().read_line(&mut answer).is_ok()
&& matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
fn main() {
let matches = Args::command().version(VERSION).get_matches();
let args = Args::from_arg_matches(&matches).expect("arguments validated by clap");
let level = if args.quiet {
tracing_subscriber::filter::LevelFilter::OFF
} else {
tracing::Level::from(args.verbose).into()
};
tracing_subscriber::fmt().with_max_level(level).init();
tracing_subscriber::fmt()
.with_max_level(level_filter)
.init();
let registry = plugin::PluginRegistry::load(&args.plugin_dirs, !args.no_default_plugins);
for diagnostic in &registry.diagnostics {
tracing::warn!("{diagnostic}");
}
let plugins = get_plugins();
if args.list_formats {
let mut formats: std::collections::HashSet<&str> = std::collections::HashSet::new();
for p in &plugins {
formats.extend(p.from_formats());
formats.extend(p.to_formats());
if args.list_plugins {
println!("Official plugins:");
for metadata in catalog::official_plugins() {
let installed = registry
.plugins
.iter()
.find(|plugin| plugin.metadata().id == metadata.id);
let state = match installed.map(|plugin| plugin.availability()) {
Some(Ok(())) => "installed".to_owned(),
Some(Err(error)) => format!("unavailable: {error}"),
None => "not installed".to_owned(),
};
println!(
" {:32} {:28} {}",
metadata.package, state, metadata.description
);
}
let mut formats_vec: Vec<_> = formats.into_iter().collect();
formats_vec.sort();
tracing::info!("Listed {} supported formats", formats_vec.len());
println!("Supported Formats:");
for chunk in formats_vec.chunks(6) {
let row = chunk.iter().map(|s| format!("{:<10}", s)).collect::<Vec<_>>().join(" ");
println!(" {}", row);
return;
}
if args.list_formats {
let mut formats = HashSet::new();
for plugin in &registry.plugins {
for conversion in plugin.metadata().conversions {
formats.insert(conversion.from);
formats.insert(conversion.to);
}
}
let mut formats: Vec<_> = formats.into_iter().collect();
formats.sort();
if formats.is_empty() {
println!("No formats are available because no plugins are installed.");
} else {
println!("{}", formats.join("\n"));
}
return;
}
let input_path = match &args.input_path {
Some(path) => path,
None => {
tracing::error!("Input file path is required unless --list-formats is used.");
process::exit(1);
}
};
tracing::debug!("Input path specified: {}", input_path);
// Read input file
let input_bytes = match fs::read(input_path) {
Ok(b) => {
tracing::trace!("Read {} bytes from {}", b.len(), input_path);
b
}
Err(e) => {
tracing::error!("Error reading input file: {}", e);
process::exit(1);
}
};
let from_format = match identifier::identify_format(input_path, &input_bytes, &plugins) {
Some(f) => {
tracing::info!("Identified input format: {}", f);
f
}
None => {
tracing::error!("Unknown input format.");
process::exit(1);
}
};
let to_format = match &args.output_path {
Some(out) => match identifier::identify_format(out, &[], &plugins) {
Some(f) => {
tracing::info!("Identified output format from path: {}", f);
f
}
None => {
tracing::error!("Unknown output format.");
process::exit(1);
}
},
None => {
// Pick a default target format based on the first available conversion
let default_target = plugins.iter().find_map(|p| {
if p.from_formats().contains(&from_format) {
p.to_formats().first().copied()
} else {
None
}
let input = args
.input_path
.as_deref()
.unwrap_or_else(|| fail("an input path is required"));
let detected = identifier::identify_path(input, args.from_format.as_deref())
.unwrap_or_else(|error| fail(error));
tracing::info!("detected {} ({:?})", detected.format, detected.media_kind);
let mut target =
identifier::requested_format(args.output_path.as_deref(), args.to_format.as_deref())
.unwrap_or_else(|| {
fail("a target is required; provide an output extension or --to FORMAT")
});
match default_target {
Some(t) => {
tracing::info!("Auto-selected target format: {}", t);
t
}
None => {
tracing::error!("No output path provided, and no available conversions found.");
process::exit(1);
}
if matches!(
detected.media_kind,
convertis_plugin_api::MediaKind::Video
| convertis_plugin_api::MediaKind::Animation
| convertis_plugin_api::MediaKind::Frames
) {
target = match target.as_str() {
"gif" => "animated-gif".to_owned(),
"webp" => "animated-webp".to_owned(),
_ => target,
};
}
let options = parse_options(&args.options).unwrap_or_else(|error| fail(error));
let mut banned = Vec::new();
let result = loop {
let Some(route) = pathfinder::find_best_path(
&registry.plugins,
&detected.format,
&target,
&args.priority,
&banned,
) else {
let packages = catalog::recommend_packages(&detected.format, &target);
if packages.is_empty() {
fail(format!(
"no conversion path from {} to {} is known",
detected.format, target
));
}
let unavailable: Vec<_> = registry
.plugins
.iter()
.filter_map(|plugin| {
let metadata = plugin.metadata();
if packages.contains(&metadata.package) {
plugin
.availability()
.err()
.map(|error| format!("{}: {error}", metadata.package))
} else {
None
}
})
.collect();
if !unavailable.is_empty() {
fail(format!(
"required plugins are installed but unavailable: {}",
unavailable.join("; ")
));
}
fail(format!(
"no installed conversion path from {} to {}. Install: {}",
detected.format,
target,
packages.join(" ")
));
};
for key in options.keys() {
let recognized = route.iter().any(|step| {
let metadata = step.plugin.metadata();
let option_name = key
.strip_prefix(&format!("{}.", metadata.id))
.unwrap_or(key);
metadata
.options
.iter()
.any(|option| option.name == option_name)
});
if !recognized {
fail(format!(
"option '{key}' is not supported by the selected conversion route"
));
}
}
};
let mut banned_plugins: Vec<&str> = Vec::new();
tracing::debug!("Starting pathfinding loop. from: {}, to: {}, priority: {}", from_format, to_format, args.priority);
let output_bytes = loop {
tracing::trace!("Finding best path with banned plugins: {:?}", banned_plugins);
let path = match pathfinder::find_best_path(&plugins, from_format, to_format, &args.priority, &banned_plugins) {
Some(p) => p,
None => {
tracing::error!("No conversion path found from {} to {}.", from_format, to_format);
process::exit(1);
}
};
let route_names: Vec<_> = route.iter().map(|step| step.plugin.metadata().id).collect();
if args.test {
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect();
tracing::info!("Test successful. Path: {}", path_str.join(" -> "));
println!("{}", route_names.join(" -> "));
return;
}
match runner::run_conversion(&path, &input_bytes, &args.temp_dir) {
Ok(output_bytes) => {
tracing::info!("Conversion successful. Output size: {} bytes", output_bytes.len());
break output_bytes;
}
Err((e, plugin_name)) => {
tracing::warn!("Conversion failed at plugin '{}': {}", plugin_name, e);
tracing::warn!("Rerouting and trying alternative paths...");
banned_plugins.push(plugin_name);
match runner::run_conversion(&route, input, &args.temp_dir, &options) {
Ok(result) => break result,
Err((error, plugin)) => {
tracing::warn!("plugin {plugin} failed: {error}; trying another route");
banned.push(plugin);
}
}
};
if let Some(out_path) = &args.output_path {
let out_path_file = std::path::Path::new(out_path);
if out_path_file.exists() && !should_overwrite(&args, out_path) {
tracing::info!("Not overwriting '{}'. Exiting.", out_path);
process::exit(1);
if let Some(output) = &args.output_path {
if !should_overwrite(&args, output) {
fail("output was not replaced");
}
if let Err(e) = fs::write(out_path, &output_bytes) {
tracing::error!("Error writing output file: {}", e);
process::exit(1);
if output.exists() {
if output.is_dir() {
fs::remove_dir_all(output).unwrap_or_else(|error| fail(error));
} else {
fs::remove_file(output).unwrap_or_else(|error| fail(error));
}
}
runner::install_result(&result, output).unwrap_or_else(|error| fail(error));
} else if result.kind == ArtifactKind::Directory {
fail("directory output requires an output path");
} else {
let is_piped = !io::stdout().is_terminal();
if !is_piped && !args.write_to_console {
tracing::warn!("Outputting directly to console. Use -c to suppress this warning, or redirect to a file.");
}
let mut stdout = io::stdout();
if let Err(e) = stdout.write_all(&output_bytes) {
tracing::error!("Error writing to console: {}", e);
process::exit(1);
let bytes = fs::read(&result.path).unwrap_or_else(|error| fail(error));
if io::stdout().is_terminal() && !args.write_to_console {
tracing::warn!(
"writing conversion bytes to the terminal; use -c to suppress this warning"
);
}
io::stdout()
.write_all(&bytes)
.unwrap_or_else(|error| fail(error));
}
}
+59 -412
View File
@@ -12,440 +12,87 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use convertis_plugin_api::{Conversion, Plugin};
use std::collections::{HashMap, VecDeque};
use std::rc::Rc;
#[derive(Clone)]
pub struct PathNode<'a> {
pub struct RouteStep<'a> {
pub plugin: &'a dyn Plugin,
pub from_format: &'a str,
pub to_format: &'a str,
pub prev: Option<Rc<PathNode<'a>>>,
pub conversion: Conversion,
}
impl<'a> PathNode<'a> {
pub fn get_path(&self) -> Vec<(&'a dyn Plugin, &'a str, &'a str)> {
let mut path = Vec::new();
path.push((self.plugin, self.from_format, self.to_format));
let mut curr = self.prev.clone();
while let Some(node) = curr {
path.push((node.plugin, node.from_format, node.to_format));
curr = node.prev.clone();
}
path.reverse();
path
}
fn route_score(route: &[RouteStep<'_>], priority: &str) -> Vec<u32> {
priority
.chars()
.map(|criterion| {
route
.iter()
.map(|step| match criterion.to_ascii_lowercase() {
'f' => step.conversion.familiarity as u32,
'q' => step.conversion.quality as u32,
's' => step.conversion.speed as u32,
_ => 0,
})
.sum()
})
.collect()
}
#[tracing::instrument(skip(plugins))]
pub fn find_best_path<'a>(
plugins: &'a [Box<dyn Plugin>],
from_format: &'a str,
to_format: &str,
from: &str,
to: &str,
priority: &str,
banned_plugins: &[&str],
) -> Option<Vec<(&'a dyn Plugin, &'a str, &'a str)>> {
let mut adj_list: HashMap<&str, Vec<&'a dyn Plugin>> = HashMap::new();
for p in plugins {
if banned_plugins.contains(&p.name()) {
tracing::trace!("Skipping banned plugin: {}", p.name());
banned: &[String],
) -> Option<Vec<RouteStep<'a>>> {
let mut edges: HashMap<String, Vec<RouteStep<'a>>> = HashMap::new();
for plugin in plugins {
let metadata = plugin.metadata();
if banned.contains(&metadata.id) || plugin.availability().is_err() {
continue;
}
if !p.is_available() {
tracing::trace!("Skipping unavailable plugin: {}", p.name());
continue;
}
for &from in &p.from_formats() {
adj_list.entry(from).or_default().push(p.as_ref());
for conversion in metadata.conversions {
edges
.entry(conversion.from.clone())
.or_default()
.push(RouteStep {
plugin: plugin.as_ref(),
conversion,
});
}
}
// BFS to find paths with least amount of conversions
let mut queue = VecDeque::new();
queue.push_back((from_format, None::<Rc<PathNode<'a>>>));
let mut min_length = None;
let mut best_paths: Vec<Vec<(&'a dyn Plugin, &'a str, &'a str)>> = Vec::new();
let mut visited_depth = HashMap::new();
visited_depth.insert(from_format, 0);
while let Some((curr_format, prev_node)) = queue.pop_front() {
let current_depth = visited_depth.get(curr_format).copied().unwrap_or(0);
tracing::trace!("Visiting format node: {} at depth {}", curr_format, current_depth);
if let Some(min_len) = min_length {
if current_depth > min_len {
tracing::trace!("Pruning path exploration at depth {} (min_len={})", current_depth, min_len);
break; // We've moved beyond the shortest paths
}
}
if curr_format == to_format && prev_node.is_some() {
if min_length.is_none() {
min_length = Some(current_depth);
}
if min_length == Some(current_depth) {
best_paths.push(prev_node.unwrap().get_path());
}
let mut queue = VecDeque::from([(from.to_owned(), Vec::<RouteStep<'a>>::new())]);
let mut depths = HashMap::from([(from.to_owned(), 0usize)]);
let mut solutions = Vec::new();
let mut minimum = None;
while let Some((current, route)) = queue.pop_front() {
if minimum.is_some_and(|depth| route.len() > depth) {
continue;
}
if let Some(neighbors) = adj_list.get(curr_format) {
for plugin in neighbors {
for &next_format in &plugin.to_formats() {
let next_depth = current_depth + 1;
let prev_depth = visited_depth
.get(next_format)
if current == to && !route.is_empty() {
minimum = Some(route.len());
solutions.push(route);
continue;
}
if let Some(next_steps) = edges.get(&current) {
for step in next_steps {
let next_depth = route.len() + 1;
if next_depth
<= depths
.get(&step.conversion.to)
.copied()
.unwrap_or(usize::MAX);
if next_depth <= prev_depth {
visited_depth.insert(next_format, next_depth);
let new_node = Rc::new(PathNode {
plugin: *plugin,
from_format: curr_format,
to_format: next_format,
prev: prev_node.clone(),
});
queue.push_back((next_format, Some(new_node)));
}
.unwrap_or(usize::MAX)
{
depths.insert(step.conversion.to.clone(), next_depth);
let mut next_route = route.clone();
next_route.push(step.clone());
queue.push_back((step.conversion.to.clone(), next_route));
}
}
}
}
if best_paths.is_empty() {
tracing::debug!("No valid paths found from {} to {}.", from_format, to_format);
return None;
}
tracing::debug!("Found {} path(s) of minimum length {}. Evaluating priority: '{}'", best_paths.len(), min_length.unwrap_or(0), priority);
// Evaluate based on priority (maximize score)
let best = best_paths
solutions
.into_iter()
.max_by(|path_a, path_b| compare_paths(path_a, path_b, priority));
if let Some(ref path) = best {
tracing::debug!("Selected path with length {}: {:?}", path.len(), path.iter().map(|p| p.0.name()).collect::<Vec<_>>());
}
best
}
fn compare_paths(
path_a: &[(&dyn Plugin, &str, &str)],
path_b: &[(&dyn Plugin, &str, &str)],
priority: &str,
) -> std::cmp::Ordering {
for ch in priority.chars() {
match ch {
'f' | 'F' => {
let score_a: u32 = path_a
.iter()
.map(|(p, from, to)| p.familiarity(*from, *to) as u32)
.sum();
let score_b: u32 = path_b
.iter()
.map(|(p, from, to)| p.familiarity(*from, *to) as u32)
.sum();
if score_a != score_b {
return score_a.cmp(&score_b);
}
}
'q' | 'Q' => {
let score_a: u32 = path_a
.iter()
.map(|(p, from, to)| p.quality(*from, *to) as u32)
.sum();
let score_b: u32 = path_b
.iter()
.map(|(p, from, to)| p.quality(*from, *to) as u32)
.sum();
if score_a != score_b {
return score_a.cmp(&score_b);
}
}
's' | 'S' => {
let score_a: u32 = path_a
.iter()
.map(|(p, from, to)| p.speed(*from, *to) as u32)
.sum();
let score_b: u32 = path_b
.iter()
.map(|(p, from, to)| p.speed(*from, *to) as u32)
.sum();
if score_a != score_b {
return score_a.cmp(&score_b);
}
}
_ => {}
}
}
std::cmp::Ordering::Equal
}
#[cfg(test)]
mod tests {
use super::*;
struct MockPlugin {
name: &'static str,
from: Vec<&'static str>,
to: Vec<&'static str>,
f: u8,
q: u8,
s: u8,
}
impl Plugin for MockPlugin {
fn name(&self) -> &'static str {
self.name
}
fn from_formats(&self) -> Vec<&'static str> {
self.from.clone()
}
fn to_formats(&self) -> Vec<&'static str> {
self.to.clone()
}
fn familiarity(&self, _from: &str, _to: &str) -> u8 {
self.f
}
fn quality(&self, _from: &str, _to: &str) -> u8 {
self.q
}
fn speed(&self, _from: &str, _to: &str) -> u8 {
self.s
}
fn convert(
&self,
_input: &[u8],
_from: &str,
_to: &str,
_temp_dir: &std::path::Path,
) -> Result<Vec<u8>, String> {
Ok(vec![])
}
}
#[test]
fn test_shortest_path() {
let plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(MockPlugin {
name: "A",
from: vec!["jpeg"],
to: vec!["png"],
f: 10,
q: 10,
s: 10,
}),
Box::new(MockPlugin {
name: "B",
from: vec!["jpeg"],
to: vec!["bmp"],
f: 10,
q: 10,
s: 10,
}),
Box::new(MockPlugin {
name: "C",
from: vec!["bmp"],
to: vec!["png"],
f: 10,
q: 10,
s: 10,
}),
];
let path = find_best_path(&plugins, "jpeg", "png", "fqs", &[]).unwrap();
assert_eq!(path.len(), 1);
assert_eq!(path[0].0.name(), "A");
}
#[test]
fn test_priority_tie_break() {
let plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(MockPlugin {
name: "A",
from: vec!["jpeg"],
to: vec!["png"],
f: 10,
q: 50,
s: 10,
}),
Box::new(MockPlugin {
name: "B",
from: vec!["jpeg"],
to: vec!["png"],
f: 50,
q: 10,
s: 10,
}),
];
let path = find_best_path(&plugins, "jpeg", "png", "fqs", &[]).unwrap();
assert_eq!(path[0].0.name(), "B");
let path = find_best_path(&plugins, "jpeg", "png", "qfs", &[]).unwrap();
assert_eq!(path[0].0.name(), "A");
}
macro_rules! test_pathfinder {
($name:ident, $from:expr, $to:expr, $priority:expr, $expected_len:expr, $expected_first:expr) => {
#[test]
fn $name() {
let plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(MockPlugin {
name: "A",
from: vec!["a"],
to: vec!["b"],
f: 10,
q: 10,
s: 10,
}),
Box::new(MockPlugin {
name: "B",
from: vec!["b"],
to: vec!["c"],
f: 20,
q: 10,
s: 10,
}),
Box::new(MockPlugin {
name: "C",
from: vec!["a"],
to: vec!["c"],
f: 5,
q: 10,
s: 10,
}),
Box::new(MockPlugin {
name: "D",
from: vec!["a"],
to: vec!["d"],
f: 10,
q: 20,
s: 10,
}),
Box::new(MockPlugin {
name: "E",
from: vec!["d"],
to: vec!["c"],
f: 10,
q: 20,
s: 10,
}),
Box::new(MockPlugin {
name: "F",
from: vec!["a"],
to: vec!["b"],
f: 50,
q: 5,
s: 5,
}), // High familiarity, low q/s
Box::new(MockPlugin {
name: "G",
from: vec!["c"],
to: vec!["e"],
f: 10,
q: 10,
s: 50,
}),
];
let path = find_best_path(&plugins, $from, $to, $priority, &[]);
if $expected_len == 0 {
assert!(path.is_none());
} else {
let p = path.unwrap();
assert_eq!(p.len(), $expected_len);
assert_eq!(p[0].0.name(), $expected_first);
}
}
};
}
test_pathfinder!(test_path_1, "a", "b", "fqs", 1, "F"); // F has higher f
test_pathfinder!(test_path_2, "a", "b", "qfs", 1, "A"); // A has higher q
test_pathfinder!(test_path_3, "a", "c", "fqs", 1, "C"); // Shortest path is length 1 (C)
test_pathfinder!(test_path_4, "a", "d", "fqs", 1, "D");
test_pathfinder!(test_path_5, "d", "c", "fqs", 1, "E");
test_pathfinder!(test_path_6, "a", "e", "fqs", 2, "C"); // Shortest path to e goes through c. So a->c (C), c->e (G)
test_pathfinder!(test_path_7, "b", "e", "fqs", 2, "B");
test_pathfinder!(test_path_8, "e", "a", "fqs", 0, ""); // No path
test_pathfinder!(test_path_9, "c", "b", "fqs", 0, ""); // No path
test_pathfinder!(test_path_10, "x", "y", "fqs", 0, ""); // No path
macro_rules! test_pathfinder_2 {
($name:ident, $from:expr, $to:expr, $priority:expr, $expected_len:expr) => {
#[test]
fn $name() {
let plugins: Vec<Box<dyn Plugin>> = vec![
Box::new(MockPlugin {
name: "1",
from: vec!["1"],
to: vec!["2"],
f: 10,
q: 10,
s: 10,
}),
Box::new(MockPlugin {
name: "2",
from: vec!["2"],
to: vec!["3"],
f: 10,
q: 10,
s: 10,
}),
Box::new(MockPlugin {
name: "3",
from: vec!["3"],
to: vec!["4"],
f: 10,
q: 10,
s: 10,
}),
Box::new(MockPlugin {
name: "4",
from: vec!["4"],
to: vec!["5"],
f: 10,
q: 10,
s: 10,
}),
Box::new(MockPlugin {
name: "5",
from: vec!["5"],
to: vec!["6"],
f: 10,
q: 10,
s: 10,
}),
];
let path = find_best_path(&plugins, $from, $to, $priority, &[]);
if $expected_len == 0 {
assert!(path.is_none());
} else {
assert_eq!(path.unwrap().len(), $expected_len);
}
}
};
}
test_pathfinder_2!(test_p2_1, "1", "2", "f", 1);
test_pathfinder_2!(test_p2_2, "1", "3", "f", 2);
test_pathfinder_2!(test_p2_3, "1", "4", "f", 3);
test_pathfinder_2!(test_p2_4, "1", "5", "f", 4);
test_pathfinder_2!(test_p2_5, "1", "6", "f", 5);
test_pathfinder_2!(test_p2_6, "2", "6", "f", 4);
test_pathfinder_2!(test_p2_7, "3", "6", "f", 3);
test_pathfinder_2!(test_p2_8, "4", "6", "f", 2);
test_pathfinder_2!(test_p2_9, "5", "6", "f", 1);
test_pathfinder_2!(test_p2_10, "6", "1", "f", 0);
.max_by_key(|route| route_score(route, priority))
}
+137 -22
View File
@@ -12,30 +12,145 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use std::path::Path;
use convertis_plugin_api::{
ENGINE_VERSION, FACTORY_SYMBOL, MANIFEST_SYMBOL, Plugin, PluginFactory, PluginManifest,
};
use libloading::Library;
use serde::Deserialize;
use std::{collections::HashSet, ffi::CStr, fs, path::PathBuf};
pub trait Plugin: Send + Sync {
fn name(&self) -> &'static str;
fn from_formats(&self) -> Vec<&'static str>;
fn to_formats(&self) -> Vec<&'static str>;
#[derive(Deserialize)]
struct AbiManifest {
api_version: u32,
engine_version: String,
plugin_id: String,
rustc_version: String,
target: String,
}
/// Checks if the plugin is available to run on this system (e.g. required binaries are installed).
fn is_available(&self) -> bool {
true
pub struct PluginRegistry {
// Plugins must be dropped before their backing libraries.
pub plugins: Vec<Box<dyn Plugin>>,
libraries: Vec<Library>,
pub diagnostics: Vec<String>,
}
impl PluginRegistry {
pub fn load(extra_dirs: &[PathBuf], include_defaults: bool) -> Self {
let mut directories = extra_dirs.to_vec();
if let Some(paths) = std::env::var_os("CONVERTIS_PLUGIN_PATH") {
directories.extend(std::env::split_paths(&paths));
}
if include_defaults {
if let Ok(executable) = std::env::current_exe()
&& let Some(parent) = executable.parent()
{
directories.push(parent.to_path_buf());
directories.push(parent.join("plugins"));
}
if let Some(home) = std::env::var_os("HOME") {
directories.push(PathBuf::from(home).join(".local/lib/convertis/plugins"));
}
directories.push(PathBuf::from("/usr/lib/convertis/plugins"));
}
let mut registry = Self {
plugins: Vec::new(),
libraries: Vec::new(),
diagnostics: Vec::new(),
};
let mut seen_paths = HashSet::new();
let mut seen_ids = HashSet::new();
for directory in directories {
let Ok(directory) = directory.canonicalize() else {
continue;
};
if !seen_paths.insert(directory.clone()) {
continue;
}
let Ok(entries) = fs::read_dir(&directory) else {
continue;
};
let mut paths: Vec<_> = entries.flatten().map(|entry| entry.path()).collect();
paths.sort();
for path in paths {
let is_plugin = path.extension().and_then(|value| value.to_str()) == Some("so")
&& path
.file_name()
.and_then(|value| value.to_str())
.is_some_and(|name| name.starts_with("libconvertis_"));
if !is_plugin {
continue;
}
match unsafe { Self::load_one(&path) } {
Ok((library, plugin, id)) => {
if seen_ids.insert(id.clone()) {
registry.plugins.push(plugin);
registry.libraries.push(library);
} else {
registry.diagnostics.push(format!(
"ignored duplicate plugin '{id}' from {}",
path.display()
));
}
}
Err(error) => registry
.diagnostics
.push(format!("could not load {}: {error}", path.display())),
}
}
}
registry
}
/// Score from 1 to 255. Higher is better.
fn familiarity(&self, from_format: &str, to_format: &str) -> u8;
fn quality(&self, from_format: &str, to_format: &str) -> u8;
fn speed(&self, from_format: &str, to_format: &str) -> u8;
/// Converts the given input bytes from `from_format` to `to_format`.
/// `temp_dir` is the directory to use for any intermediate scratch files.
fn convert(
&self,
input: &[u8],
from_format: &str,
to_format: &str,
temp_dir: &Path,
) -> Result<Vec<u8>, String>;
unsafe fn load_one(
path: &std::path::Path,
) -> Result<(Library, Box<dyn Plugin>, String), String> {
let library = unsafe { Library::new(path) }.map_err(|error| error.to_string())?;
let manifest_fn = unsafe { library.get::<PluginManifest>(MANIFEST_SYMBOL) }
.map_err(|error| format!("missing ABI manifest: {error}"))?;
let pointer = unsafe { manifest_fn() };
if pointer.is_null() {
return Err("ABI manifest was null".to_owned());
}
let json = unsafe { CStr::from_ptr(pointer) }
.to_str()
.map_err(|error| format!("invalid ABI manifest string: {error}"))?;
let manifest: AbiManifest =
serde_json::from_str(json).map_err(|error| format!("invalid ABI manifest: {error}"))?;
if manifest.api_version != convertis_plugin_api::API_VERSION {
return Err(format!(
"plugin API {} is not supported",
manifest.api_version
));
}
if manifest.engine_version != ENGINE_VERSION {
return Err(format!(
"plugin targets engine {}, but this engine is {}",
manifest.engine_version, ENGINE_VERSION
));
}
if manifest.rustc_version != env!("CONVERTIS_RUSTC_VERSION") {
return Err(format!(
"plugin was built with {}, but the engine uses {}",
manifest.rustc_version,
env!("CONVERTIS_RUSTC_VERSION")
));
}
if manifest.target != env!("CONVERTIS_TARGET") {
return Err(format!(
"plugin targets {}, but the engine targets {}",
manifest.target,
env!("CONVERTIS_TARGET")
));
}
let factory = unsafe { library.get::<PluginFactory>(FACTORY_SYMBOL) }
.map_err(|error| format!("missing Rust plugin factory: {error}"))?;
let plugin = unsafe { factory() };
if plugin.metadata().id != manifest.plugin_id {
return Err("manifest and plugin IDs differ".to_owned());
}
Ok((library, plugin, manifest.plugin_id))
}
}
+112 -113
View File
@@ -12,127 +12,126 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::plugin::Plugin;
use std::path::Path;
use std::time::Instant;
use crate::pathfinder::RouteStep;
use convertis_plugin_api::{ArtifactKind, ConversionRequest};
use std::{
collections::BTreeMap,
fs, io,
path::{Path, PathBuf},
};
pub struct ConversionResult {
pub path: PathBuf,
pub kind: ArtifactKind,
_workspace: tempfile::TempDir,
}
#[tracing::instrument(skip(path, input, temp_dir))]
pub fn run_conversion(
path: &[(&dyn Plugin, &str, &str)],
input: &[u8],
temp_dir: &Path,
) -> Result<Vec<u8>, (String, &'static str)> {
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect();
tracing::info!("Path taken: {}", path_str.join(" -> "));
route: &[RouteStep<'_>],
input: &Path,
temp_root: &Path,
options: &BTreeMap<String, String>,
) -> Result<ConversionResult, (String, String)> {
fs::create_dir_all(temp_root).map_err(|error| (error.to_string(), "engine".to_owned()))?;
let workspace = tempfile::Builder::new()
.prefix("convertis-")
.tempdir_in(temp_root)
.map_err(|error| (error.to_string(), "engine".to_owned()))?;
let mut current = input.to_path_buf();
let mut kind = if input.is_dir() {
ArtifactKind::Directory
} else {
ArtifactKind::File
};
let mut current_data = input.to_vec();
for (plugin, from_format, to_format) in path {
tracing::info!(
"Converting {} to {} using plugin {}...",
from_format,
to_format,
plugin.name()
);
tracing::debug!("[Plugin Log] Running plugin: {}", plugin.name());
tracing::debug!("[Plugin Log] Source format: {}", from_format);
tracing::debug!("[Plugin Log] Target format: {}", to_format);
tracing::debug!(
"[Plugin Log] Metrics - Familiarity: {}, Quality: {}, Speed: {}",
plugin.familiarity(*from_format, *to_format),
plugin.quality(*from_format, *to_format),
plugin.speed(*from_format, *to_format)
);
tracing::debug!("[Plugin Log] Input data size: {} bytes", current_data.len());
let start_time = Instant::now();
current_data = match plugin.convert(&current_data, from_format, to_format, temp_dir) {
Ok(data) => data,
Err(e) => return Err((e, plugin.name())),
};
let elapsed = start_time.elapsed();
tracing::info!(
"Step '{} -> {}' completed in {:?}. Output size: {} bytes",
from_format, to_format, elapsed, current_data.len()
);
}
Ok(current_data)
}
#[cfg(test)]
mod tests {
use super::*;
struct MockPlugin {
name: &'static str,
f: u8,
q: u8,
s: u8,
fail: bool,
}
impl Plugin for MockPlugin {
fn name(&self) -> &'static str {
self.name
}
fn from_formats(&self) -> Vec<&'static str> {
vec!["a"]
}
fn to_formats(&self) -> Vec<&'static str> {
vec!["b"]
}
fn familiarity(&self, _from: &str, _to: &str) -> u8 {
self.f
}
fn quality(&self, _from: &str, _to: &str) -> u8 {
self.q
}
fn speed(&self, _from: &str, _to: &str) -> u8 {
self.s
}
fn convert(
&self,
input: &[u8],
_from: &str,
_to: &str,
_temp_dir: &Path,
) -> Result<Vec<u8>, String> {
if self.fail {
return Err("Failed".to_string());
for (index, step) in route.iter().enumerate() {
let metadata = step.plugin.metadata();
let mut plugin_options = BTreeMap::new();
for option in &metadata.options {
if let Some(default) = &option.default {
plugin_options.insert(option.name.clone(), default.clone());
}
let mut out = input.to_vec();
out.push(1);
Ok(out)
}
}
macro_rules! test_runner {
($name:ident, $fail:expr, $expected_res:expr, $expected_len:expr) => {
#[test]
fn $name() {
let plugin = MockPlugin {
name: "mock",
f: 10,
q: 10,
s: 10,
fail: $fail,
};
let path: Vec<(&dyn Plugin, &str, &str)> = vec![(&plugin, "a", "b")];
let input = vec![0];
let result =
run_conversion(&path, &input, std::path::Path::new("/dev/shm"));
assert_eq!(result.is_ok(), $expected_res);
if let Ok(res) = result {
assert_eq!(res.len(), $expected_len);
for (key, value) in options {
if let Some((plugin_id, option)) = key.split_once('.') {
if plugin_id == metadata.id {
plugin_options.insert(option.to_owned(), value.clone());
}
} else if metadata.options.iter().any(|option| option.name == *key) {
plugin_options.insert(key.clone(), value.clone());
}
}
let output = match step.conversion.output_kind {
ArtifactKind::File => workspace.path().join(format!(
"step-{index}.{}",
file_extension(&step.conversion.to)
)),
ArtifactKind::Directory => workspace.path().join(format!("step-{index}")),
};
if step.conversion.output_kind == ArtifactKind::Directory {
fs::create_dir_all(&output)
.map_err(|error| (error.to_string(), "engine".to_owned()))?;
}
let request = ConversionRequest {
input: current,
output: output.clone(),
from: step.conversion.from.clone(),
to: step.conversion.to.clone(),
options: plugin_options,
};
step.plugin
.convert(&request)
.map_err(|error| (error, step.plugin.metadata().id))?;
current = output;
kind = step.conversion.output_kind;
}
Ok(ConversionResult {
path: current,
kind,
_workspace: workspace,
})
}
test_runner!(test_runner_verb_0, false, true, 2);
test_runner!(test_runner_verb_0_fail, true, false, 0);
fn file_extension(format: &str) -> &str {
match format {
"animated-gif" => "gif",
"animated-webp" => "webp",
"text" => "txt",
other => other,
}
}
pub fn install_result(result: &ConversionResult, destination: &Path) -> io::Result<()> {
let parent = destination.parent().unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)?;
match result.kind {
ArtifactKind::File => {
let staging = tempfile::NamedTempFile::new_in(parent)?;
fs::copy(&result.path, staging.path())?;
staging.persist(destination).map_err(|error| error.error)?;
}
ArtifactKind::Directory => {
let staging = tempfile::Builder::new()
.prefix(".convertis-output-")
.tempdir_in(parent)?;
let payload = staging.path().join("payload");
copy_directory(&result.path, &payload)?;
fs::rename(payload, destination)?;
}
}
Ok(())
}
fn copy_directory(source: &Path, destination: &Path) -> io::Result<()> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_directory(&entry.path(), &target)?;
} else {
fs::copy(entry.path(), target)?;
}
}
Ok(())
}