Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Pull Request CI

on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]

permissions:
contents: read

concurrency:
group: pr-ci-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
validate:
name: Validate dev to main
if: github.event.pull_request.head.ref == 'dev'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"

- name: Restore
run: dotnet restore src/Telemorph.slnx

- name: Verify formatting
run: dotnet format src/Telemorph.slnx --verify-no-changes --no-restore

- name: Build
run: dotnet build src/Telemorph.slnx -c Release --no-restore -warnaserror

- name: Test
run: dotnet test src/Telemorph.slnx -c Release --no-build --no-restore
107 changes: 90 additions & 17 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
name: Build and Release

on:
push:
branches: [main]
tags:
- "v*"
release:
types: [published]
workflow_dispatch:

permissions:
Expand All @@ -17,6 +15,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.ref }}

- name: Setup .NET
uses: actions/setup-dotnet@v4
Expand Down Expand Up @@ -53,6 +53,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || github.ref }}

- name: Setup .NET
uses: actions/setup-dotnet@v4
Expand Down Expand Up @@ -93,22 +95,24 @@ jobs:
Copy-Item -LiteralPath $ffprobe -Destination "$toolDir/ffprobe.exe"
Copy-Item LICENSE, THIRD_PARTY_NOTICES.md -Destination "publish/${{ matrix.rid }}"

- name: Bundle pinned FFmpeg (Windows ARM64)
- name: Bundle compatible FFmpeg (Windows ARM64)
if: matrix.rid == 'win-arm64'
shell: pwsh
run: |
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-10-31-13-40/ffmpeg-n8.0-30-g71007e6c12-winarm64-gpl-8.0.zip"
$expectedSha256 = "5276af84b736b279a70514c5b7e236f03961ab5517d911a88371838459b94c40"
Invoke-WebRequest -Uri $url -OutFile ffmpeg-win-arm64.zip
$actualSha256 = (Get-FileHash ffmpeg-win-arm64.zip -Algorithm SHA256).Hash.ToLowerInvariant()
# The native BtbN Windows ARM64 build does not include libvpx-vp9.
# Windows 11 ARM can run this verified x64 media backend through emulation.
$url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-10-31-13-40/ffmpeg-n8.0-30-g71007e6c12-win64-gpl-8.0.zip"
$expectedSha256 = "05ecc01bb03ef1f4d908c3c982512f07f888848429be6e9662a5a7c558c60b4f"
Invoke-WebRequest -Uri $url -OutFile ffmpeg-win-x64.zip
$actualSha256 = (Get-FileHash ffmpeg-win-x64.zip -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualSha256 -ne $expectedSha256) {
throw "FFmpeg archive checksum mismatch. Expected $expectedSha256, got $actualSha256."
}
Expand-Archive ffmpeg-win-arm64.zip -DestinationPath ffmpeg-win-arm64
Expand-Archive ffmpeg-win-x64.zip -DestinationPath ffmpeg-win-x64
$toolDir = "publish/${{ matrix.rid }}/tools/${{ matrix.rid }}"
New-Item -ItemType Directory -Force -Path $toolDir | Out-Null
$ffmpeg = Get-ChildItem ffmpeg-win-arm64 -Recurse -Filter ffmpeg.exe | Select-Object -First 1
$ffprobe = Get-ChildItem ffmpeg-win-arm64 -Recurse -Filter ffprobe.exe | Select-Object -First 1
$ffmpeg = Get-ChildItem ffmpeg-win-x64 -Recurse -Filter ffmpeg.exe | Select-Object -First 1
$ffprobe = Get-ChildItem ffmpeg-win-x64 -Recurse -Filter ffprobe.exe | Select-Object -First 1
if (-not $ffmpeg -or -not $ffprobe) {
throw "The verified FFmpeg archive does not contain ffmpeg.exe and ffprobe.exe."
}
Expand All @@ -132,8 +136,77 @@ jobs:
- name: Verify packaged toolchain
shell: pwsh
run: |
$publishDir = "publish/${{ matrix.rid }}"
$executable = if ($IsWindows) { "telemorph.exe" } else { "telemorph" }
& "publish/${{ matrix.rid }}/$executable" --doctor
$toolSuffix = if ($IsWindows) { ".exe" } else { "" }
$app = Join-Path $publishDir $executable
$toolDir = Join-Path $publishDir "tools/${{ matrix.rid }}"
$ffmpeg = Join-Path $toolDir "ffmpeg$toolSuffix"
$ffprobe = Join-Path $toolDir "ffprobe$toolSuffix"

& $app --doctor
if ($LASTEXITCODE -ne 0) {
throw "Packaged toolchain diagnostics failed with exit code $LASTEXITCODE."
}

$smokeDir = Join-Path $env:RUNNER_TEMP "telemorph-smoke-${{ matrix.rid }}"
New-Item -ItemType Directory -Force -Path $smokeDir | Out-Null
$smokeInput = Join-Path $smokeDir "transparent.apng"
$smokeOutput = Join-Path $smokeDir "output.webm"

& $ffmpeg `
-hide_banner -loglevel error -y `
-f lavfi `
-i "nullsrc=size=64x64:rate=10:duration=0.5,format=rgba,colorchannelmixer=aa=0.5" `
-frames:v 5 -plays 0 `
$smokeInput
if ($LASTEXITCODE -ne 0) {
throw "Failed to create the transparent APNG smoke-test input."
}

$inputProbeOutput = & $ffprobe `
-v error `
-select_streams v:0 `
-show_entries "stream=pix_fmt" `
-of json `
$smokeInput
if ($LASTEXITCODE -ne 0) {
throw "ffprobe failed to inspect the smoke-test input."
}

$inputPixelFormat = ($inputProbeOutput | ConvertFrom-Json).streams[0].pix_fmt
if ($inputPixelFormat -notmatch "a") {
throw "Smoke-test input pixel format '$inputPixelFormat' has no alpha channel."
}

& $app `
$smokeInput `
--emoji `
--duration 1 `
--no-optimize `
--crf 45 `
--output $smokeOutput
if ($LASTEXITCODE -ne 0) {
throw "Packaged Telemorph smoke conversion failed with exit code $LASTEXITCODE."
}

$probeOutput = & $ffprobe `
-v error `
-select_streams v:0 `
-show_entries "stream=codec_name:stream_tags=alpha_mode" `
-of json `
$smokeOutput
if ($LASTEXITCODE -ne 0) {
throw "ffprobe failed to inspect the smoke-test output."
}

$stream = ($probeOutput | ConvertFrom-Json).streams[0]
if ($stream.codec_name -ne "vp9") {
throw "Smoke-test output codec is '$($stream.codec_name)', expected 'vp9'."
}
if ($stream.tags.ALPHA_MODE -ne "1") {
throw "Smoke-test output does not advertise WebM alpha metadata."
}

- name: Create archive (Windows)
if: runner.os == 'Windows'
Expand All @@ -153,9 +226,9 @@ jobs:
if-no-files-found: error

release:
name: Create Release
name: Attach release assets
needs: build
if: startsWith(github.ref, 'refs/tags/v')
if: github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
contents: write
Expand All @@ -165,8 +238,8 @@ jobs:
with:
path: ./artifacts

- name: Create release
- name: Upload assets to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.release.tag_name }}
files: ./artifacts/**/*
generate_release_notes: true
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,12 @@ metadata and validates every encoded candidate.

## Release packaging

The release workflow publishes self-contained builds for:
Pull requests from `dev` to `main` run formatting checks, a warnings-as-errors
build, and the full test suite. Pushing or merging a commit into `main` does not
create release binaries.

Publishing a GitHub Release starts the release workflow from that release's tag
and produces self-contained builds for:

- `win-x64`
- `win-arm64`
Expand All @@ -134,9 +139,11 @@ The release workflow publishes self-contained builds for:
- `osx-arm64`

FFmpeg and ffprobe are restored from exact npm dependency versions using the
committed lockfile. Windows ARM64 uses a pinned native BtbN archive with an
explicit SHA-256 check. The tools are copied under `tools/<RID>/` and verified
by running `telemorph --doctor` before an archive is uploaded.
committed lockfile. The Windows ARM64 release uses a pinned BtbN x64 media
backend through Windows 11 ARM emulation because the native build lacks
`libvpx-vp9`; its archive has an explicit SHA-256 check. The tools are copied
under `tools/<RID>/`, checked with `telemorph --doctor`, and exercised by a
transparent APNG-to-VP9 WebM smoke test before an archive is uploaded.

See [third-party notices](THIRD_PARTY_NOTICES.md) for FFmpeg licensing and
source information.
Expand Down
10 changes: 6 additions & 4 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ The packaged executables are obtained through the pinned `ffmpeg-static` 5.3.0
and `@derhuerst/ffprobe-static` 5.3.0 npm packages. The exact npm package
contents are verified by the committed `eng/ffmpeg/package-lock.json`.

The Windows ARM64 archive uses the native BtbN FFmpeg build
`ffmpeg-n8.0-30-g71007e6c12-winarm64-gpl-8.0.zip`, pinned to its immutable
release URL and verified against SHA-256
`5276af84b736b279a70514c5b7e236f03961ab5517d911a88371838459b94c40`.
The Windows ARM64 Telemorph archive uses the BtbN x64 FFmpeg build
`ffmpeg-n8.0-30-g71007e6c12-win64-gpl-8.0.zip` because the corresponding native
Windows ARM64 build does not contain the required `libvpx-vp9` encoder. Windows
11 ARM runs this media backend through x64 emulation. The archive is pinned to
its immutable release URL and verified against SHA-256
`05ecc01bb03ef1f4d908c3c982512f07f888848429be6e9662a5a7c558c60b4f`.

FFmpeg is licensed separately from Telemorph. The packaged builds can include
GPL-licensed components. Copyright, license, corresponding-source, and build
Expand Down
2 changes: 1 addition & 1 deletion src/Telemorph.Core/Pipeline/FfmpegEncoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ private static void ReportFfmpegProgress(

const string prefix = "out_time_us=";
if (!line.StartsWith(prefix, StringComparison.Ordinal) ||
!long.TryParse(line.AsSpan(prefix.Length),NumberStyles.Integer, CultureInfo.InvariantCulture, out var microseconds))
!long.TryParse(line.AsSpan(prefix.Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out var microseconds))
return;

var duration = Math.Max(plan.Profile.MaxDurationSeconds, 0.001);
Expand Down
Loading