refactor: restructure project into a workspace by introducing a plugin API crate and modularizing individual plugin definitions.
This commit is contained in:
+13
-16
@@ -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
@@ -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,5 +1,4 @@
|
||||
/target
|
||||
AGENT.md
|
||||
ARCHITECTURE.md
|
||||
Cargo.lock
|
||||
examples
|
||||
|
||||
Generated
+1533
File diff suppressed because it is too large
Load Diff
+45
-39
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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)"
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
'&' => "&".to_owned(),
|
||||
'<' => "<".to_owned(),
|
||||
'>' => ">".to_owned(),
|
||||
'"' => """.to_owned(),
|
||||
'\'' => "'".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("<script>alert('x')</script>"));
|
||||
assert!(!html.contains("<script>"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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");
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ®istry.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 ®istry.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(
|
||||
®istry.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
@@ -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(¤t) {
|
||||
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
@@ -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
@@ -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(¤t_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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user