diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml
new file mode 100644
index 0000000..8df8073
--- /dev/null
+++ b/.github/workflows/native.yml
@@ -0,0 +1,257 @@
+name: Native AOT (ytx)
+
+# Builds NativeAOT binaries for each platform, smoke-tests them offline, and attaches them to a
+# GitHub Release. This is separate from publish.yml on purpose: the NuGet tool release is unchanged.
+#
+# Trap: publish.yml creates releases with GITHUB_TOKEN, and events caused by GITHUB_TOKEN do not
+# start other workflows. So `release: published` only fires for releases made by a person (or a PAT).
+# publish.yml therefore dispatches this workflow with the new tag as its last step.
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Existing release tag (vX.Y.Z) to attach binaries to. Leave empty to build only.
+ required: false
+ default: ''
+ type: string
+ # No branch filter, so stacked PRs (based on another feature branch) also build.
+ pull_request:
+ paths:
+ - 'src/**'
+ - 'scripts/aot/**'
+ - '.github/workflows/native.yml'
+ release:
+ types: [published]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: native-${{ github.event.release.tag_name || inputs.tag || github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+env:
+ PROJECT: src/Ytx/Ytx.csproj
+ TFM: net10.0
+ DOTNET_NOLOGO: 'true'
+ DOTNET_CLI_TELEMETRY_OPTOUT: 'true'
+
+jobs:
+ meta:
+ runs-on: ubuntu-latest
+ outputs:
+ ref: ${{ steps.meta.outputs.ref }}
+ tag: ${{ steps.meta.outputs.tag }}
+ version: ${{ steps.meta.outputs.version }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+ with:
+ ref: ${{ github.event.release.tag_name || inputs.tag || github.sha }}
+
+ - name: Determine version
+ id: meta
+ shell: bash
+ env:
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ INPUT_TAG: ${{ inputs.tag }}
+ RUN_NUMBER: ${{ github.run_number }}
+ run: |
+ set -euo pipefail
+
+ CSPROJ_VERSION=$(sed -n 's:.*\([^<]*\).*:\1:p' "$PROJECT")
+ TAG=${RELEASE_TAG:-$INPUT_TAG}
+
+ if [[ -n "$TAG" ]]; then
+ if [[ ! "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
+ echo "Tag must look like vX.Y.Z: $TAG" >&2
+ exit 1
+ fi
+ VERSION=${BASH_REMATCH[1]}
+ if [[ "$VERSION" != "$CSPROJ_VERSION" ]]; then
+ echo "Tag $TAG does not match $CSPROJ_VERSION at that tag." >&2
+ exit 1
+ fi
+ REF=$TAG
+ else
+ # Build-only runs get a prerelease version so their binaries are never mistaken for a release.
+ VERSION="$CSPROJ_VERSION-ci.$RUN_NUMBER"
+ REF=$GITHUB_SHA
+ fi
+
+ echo "Version: $VERSION Tag: ${TAG:-(none)} Ref: $REF"
+ {
+ echo "version=$VERSION"
+ echo "tag=$TAG"
+ echo "ref=$REF"
+ } >> "$GITHUB_OUTPUT"
+
+ build:
+ needs: meta
+ name: build (${{ matrix.rid }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - rid: osx-arm64
+ os: macos-latest
+ smoke: true
+ # Cross-architecture: Xcode on the arm64 runner ships the x64 toolchain, so this needs no extra
+ # setup. The binary cannot run here (no Rosetta guarantee), so smoke-osx-x64 runs it on Intel.
+ - rid: osx-x64
+ os: macos-latest
+ smoke: false
+ # Build on the oldest supported Ubuntu: the binary needs the glibc it was linked against or newer.
+ - rid: linux-x64
+ os: ubuntu-22.04
+ smoke: true
+ - rid: linux-arm64
+ os: ubuntu-22.04-arm
+ smoke: true
+ - rid: win-x64
+ os: windows-latest
+ smoke: true
+ runs-on: ${{ matrix.os }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+ with:
+ ref: ${{ needs.meta.outputs.ref }}
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Install NativeAOT prerequisites (Linux)
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends clang zlib1g-dev
+
+ - name: Publish NativeAOT
+ shell: bash
+ env:
+ RID: ${{ matrix.rid }}
+ VERSION: ${{ needs.meta.outputs.version }}
+ run: |
+ dotnet publish "$PROJECT" -c Release -f "$TFM" -r "$RID" \
+ -p:PublishAot=true -p:Version="$VERSION" -o out
+ ls -la out
+
+ - name: Stage binary
+ id: stage
+ shell: bash
+ env:
+ RID: ${{ matrix.rid }}
+ VERSION: ${{ needs.meta.outputs.version }}
+ run: |
+ set -euo pipefail
+ exe=""
+ [[ "$RID" == win-* ]] && exe=".exe"
+
+ # The assembly is named Ytx; ship the command as lower-case ytx. Copy into a fresh folder
+ # instead of renaming in place, because macOS and Windows file systems ignore case.
+ mkdir -p pkg
+ cp "out/Ytx$exe" "pkg/ytx$exe"
+ cp README.md pkg/
+
+ name="ytx-$VERSION-$RID"
+ echo "name=$name" >> "$GITHUB_OUTPUT"
+ echo "binary=pkg/ytx$exe" >> "$GITHUB_OUTPUT"
+ du -h "pkg/ytx$exe"
+
+ - name: Report glibc requirement (Linux)
+ if: runner.os == 'Linux'
+ run: |
+ echo "Highest GLIBC symbol version required:"
+ objdump -T pkg/ytx | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1
+ ldd pkg/ytx
+
+ - name: Smoke test
+ if: matrix.smoke
+ shell: bash
+ env:
+ BINARY: ${{ steps.stage.outputs.binary }}
+ VERSION: ${{ needs.meta.outputs.version }}
+ run: scripts/aot/smoke-test.sh "$BINARY" "$VERSION"
+
+ - name: Archive (tar.gz)
+ if: runner.os != 'Windows'
+ shell: bash
+ env:
+ NAME: ${{ steps.stage.outputs.name }}
+ run: tar -czf "$NAME.tar.gz" -C pkg ytx README.md
+
+ - name: Archive (zip)
+ if: runner.os == 'Windows'
+ shell: pwsh
+ env:
+ NAME: ${{ steps.stage.outputs.name }}
+ run: Compress-Archive -Path pkg/* -DestinationPath "$env:NAME.zip"
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: native-${{ matrix.rid }}
+ path: ${{ steps.stage.outputs.name }}.*
+ if-no-files-found: error
+ retention-days: 14
+
+ # GitHub's Intel macOS runner. GitHub has said x64 macOS runners end when the macOS 15 image
+ # retires (announced for fall 2027); after that, drop this job and ship osx-x64 untested or not at all.
+ smoke-osx-x64:
+ needs: [meta, build]
+ runs-on: macos-15-intel
+ steps:
+ - name: Checkout smoke test
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+ with:
+ ref: ${{ needs.meta.outputs.ref }}
+ sparse-checkout: scripts/aot
+
+ - name: Download osx-x64 build
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ name: native-osx-x64
+ path: dist
+
+ - name: Smoke test
+ shell: bash
+ env:
+ VERSION: ${{ needs.meta.outputs.version }}
+ run: |
+ set -euo pipefail
+ mkdir pkg
+ tar -xzf dist/ytx-*-osx-x64.tar.gz -C pkg
+ file pkg/ytx
+ scripts/aot/smoke-test.sh pkg/ytx "$VERSION"
+
+ release:
+ if: needs.meta.outputs.tag != ''
+ needs: [meta, build, smoke-osx-x64]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Download all builds
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ pattern: native-*
+ merge-multiple: true
+ path: dist
+
+ - name: Write SHA256SUMS
+ working-directory: dist
+ run: |
+ sha256sum ytx-* > SHA256SUMS
+ cat SHA256SUMS
+
+ - name: Upload release assets
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ needs.meta.outputs.tag }}
+ run: |
+ # --clobber makes a re-run replace the assets instead of failing on duplicates.
+ gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 671d1e0..a24fed6 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -71,6 +71,8 @@ jobs:
publish:
if: github.event_name != 'pull_request'
permissions:
+ # actions: write lets this job start native.yml; see the last step.
+ actions: write
contents: write
id-token: write
runs-on: ubuntu-latest
@@ -224,3 +226,11 @@ jobs:
--title "ytx v$VERSION" \
--generate-notes
fi
+
+ # Releases made with GITHUB_TOKEN do not fire `release: published` in other workflows,
+ # so start the native build explicitly. It attaches the binaries to this release.
+ - name: Build native binaries
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ steps.bump.outputs.tag }}
+ run: gh workflow run native.yml --ref master -f tag="$TAG"
diff --git a/AGENTS.md b/AGENTS.md
index 9c1d9e3..0a1984d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -70,6 +70,14 @@ dotnet tool install -g solrevdev.ytx --add-source ./nupkg
- System.Text.Json for serialization
- System.Text.RegularExpressions for caption text normalization
+## Native Builds and Benchmarks
+
+- `docs/investigations/` holds write-ups of past investigations. Start with `2026-10-native-aot-and-allocations.md`.
+- NativeAOT: `dotnet publish src/Ytx -c Release -f net10.0 -r -p:PublishAot=true`, then `scripts/aot/smoke-test.sh `. Keep JSON on the source-generated `YtxJsonContext`; reflection-based `JsonSerializer` calls break AOT.
+- `.github/workflows/native.yml` builds per-RID binaries and attaches them to a release. It does not publish to NuGet; `publish.yml` dispatches it with the new tag after each release.
+- `benchmarks/Ytx.Benchmarks/` is standalone and not part of CI. Never commit captured YouTube captions; `data/` is gitignored.
+- `packaging/` holds Homebrew and Scoop templates. They are not wired up yet.
+
## Repository Conventions
### Git commits
diff --git a/README.md b/README.md
index dc9a8fb..e16bc5b 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ A .NET global tool that extracts YouTube video metadata and transcripts as JSON.
- ๐ **Captions-aware** โ Prefers human-written captions in your language, reports which track it used
- ๐ **Markdown transcript** โ Human-readable format with timestamped links, split into sections by chapter
- ๐ **Chapters** โ Read from the video description, using YouTube's own rules
-- ๐ **Cross-platform** โ Works on macOS, Windows, Linux (.NET 8/9/10)
+- ๐ **Cross-platform** โ Works on macOS, Windows, Linux (.NET 8/9/10), or as a native binary with no .NET runtime
- โก **Fast & lightweight** โ No dependencies beyond .NET and YoutubeExplode
## Quick Start
@@ -46,6 +46,8 @@ A .NET global tool that extracts YouTube video metadata and transcripts as JSON.
dotnet tool install -g solrevdev.ytx
```
+This needs the .NET 8, 9 or 10 SDK. To run ytx without .NET, use a [native binary](#native-binaries).
+
### Usage
```bash
@@ -123,6 +125,113 @@ ytx --metadata-only --compact "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
dotnet tool update -g solrevdev.ytx
```
+## Native binaries
+
+Each GitHub Release from the first one after NativeAOT support also has a self-contained binary for each platform. It is compiled ahead of time, so it needs no .NET runtime. The JSON output is byte-for-byte the same as the NuGet tool's.
+
+### NuGet tool or native binary?
+
+| | NuGet tool | Native binary |
+|---|---|---|
+| Needs | .NET 8, 9 or 10 SDK | Nothing on macOS and Windows; OpenSSL and CA certificates on Linux |
+| Install and update | `dotnet tool install/update -g` | Download an archive, or a package manager later (see below) |
+| Startup | about 38 ms | about 9 ms |
+| CPU for a real call | 300โ400 ms (mostly JIT) | 47โ73 ms |
+| Peak memory | about 110โ130 MB | about 55โ60 MB |
+| Size | about 1.3 MB plus the runtime | about 10โ11 MB, one file |
+| Platforms | Anywhere .NET runs | macOS arm64 and x64, Linux x64 and arm64 (glibc), Windows x64 |
+
+Wall time for a real call is mostly network, about 1โ1.5 s either way. The numbers come from [the NativeAOT investigation](docs/investigations/2026-10-native-aot-and-allocations.md).
+
+Choose the native binary when:
+
+- **CI or containers:** there is no .NET SDK, or you don't want to install one just for ytx. A single file in a slim image or on a runner is enough.
+- **Many short calls:** scripts and agents that call ytx in a loop save the JIT start-up and CPU on every call.
+- **Locked-down or minimal machines:** no global tool path, no runtime to keep patched.
+- **Experimenting:** try ytx, or compare it with other tools, without touching your .NET setup.
+
+Stay with the NuGet tool if you already have .NET, want `dotnet tool update`, or are on a platform without a binary (such as Windows arm64 or Alpine/musl).
+
+### Install on macOS
+
+```bash
+VERSION=$(curl -fsSL https://api.github.com/repos/solrevdev/solrevdev.ytx/releases/latest | sed -n 's/.*"tag_name": "v\(.*\)".*/\1/p')
+ARCH=$([ "$(uname -m)" = arm64 ] && echo osx-arm64 || echo osx-x64)
+cd "$(mktemp -d)"
+curl -fsSLO "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$VERSION/ytx-$VERSION-$ARCH.tar.gz"
+curl -fsSLO "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$VERSION/SHA256SUMS"
+shasum -a 256 --ignore-missing -c SHA256SUMS
+tar -xzf "ytx-$VERSION-$ARCH.tar.gz"
+mkdir -p ~/.local/bin && mv ytx ~/.local/bin/ # make sure ~/.local/bin is on your PATH
+ytx --version
+```
+
+The binaries are ad-hoc signed, not notarised. `curl` does not mark files as quarantined, so this works as is. If you download the archive in a browser, Gatekeeper blocks it; clear the flag with `xattr -d com.apple.quarantine ~/.local/bin/ytx`.
+
+### Install on Linux
+
+```bash
+VERSION=$(curl -fsSL https://api.github.com/repos/solrevdev/solrevdev.ytx/releases/latest | sed -n 's/.*"tag_name": "v\(.*\)".*/\1/p')
+ARCH=$([ "$(uname -m)" = aarch64 ] && echo linux-arm64 || echo linux-x64)
+cd "$(mktemp -d)"
+curl -fsSLO "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$VERSION/ytx-$VERSION-$ARCH.tar.gz"
+curl -fsSLO "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$VERSION/SHA256SUMS"
+sha256sum --ignore-missing -c SHA256SUMS
+tar -xzf "ytx-$VERSION-$ARCH.tar.gz"
+mkdir -p ~/.local/bin && mv ytx ~/.local/bin/
+ytx --version
+```
+
+- **glibc 2.34 or newer** is required: Ubuntu 22.04+, Debian 12+, RHEL/Rocky/Alma 9+, Fedora 35+. Alpine (musl) is not supported; use the NuGet tool there.
+- **OpenSSL and CA certificates** are loaded at run time for HTTPS. Slim images often lack them. On Debian or Ubuntu: `apt-get install -y libssl3 ca-certificates` (`libssl3t64` on Ubuntu 24.04).
+- ICU is not needed.
+
+### Install on Windows
+
+```powershell
+$version = (Invoke-RestMethod https://api.github.com/repos/solrevdev/solrevdev.ytx/releases/latest).tag_name.TrimStart('v')
+$base = "https://github.com/solrevdev/solrevdev.ytx/releases/download/v$version"
+$dir = "$env:LOCALAPPDATA\Programs\ytx"
+New-Item -ItemType Directory -Force $dir | Out-Null
+Invoke-WebRequest "$base/ytx-$version-win-x64.zip" -OutFile "$env:TEMP\ytx.zip"
+Invoke-WebRequest "$base/SHA256SUMS" -OutFile "$env:TEMP\SHA256SUMS"
+$expected = (Select-String -SimpleMatch "ytx-$version-win-x64.zip" "$env:TEMP\SHA256SUMS").Line.Split(' ')[0]
+if ((Get-FileHash "$env:TEMP\ytx.zip").Hash -ne $expected) { throw "Checksum mismatch" }
+Expand-Archive "$env:TEMP\ytx.zip" -DestinationPath $dir -Force
+[Environment]::SetEnvironmentVariable('Path', "$([Environment]::GetEnvironmentVariable('Path','User'));$dir", 'User')
+& "$dir\ytx.exe" --version # open a new terminal to use plain `ytx`
+```
+
+SmartScreen may warn on first run because the binary is not code-signed.
+
+### Use in GitHub Actions
+
+```yaml
+- name: Install ytx
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release download --repo solrevdev/solrevdev.ytx --pattern 'ytx-*-linux-x64.tar.gz' --pattern SHA256SUMS
+ sha256sum --ignore-missing -c SHA256SUMS
+ sudo tar -xzf ytx-*-linux-x64.tar.gz -C /usr/local/bin ytx
+ ytx --version
+```
+
+YouTube blocks many cloud IP addresses, so captions often come back as `captionStatus: "blocked"` on hosted runners. Set `HTTPS_PROXY` or pass `--proxy` with a residential proxy.
+
+### Homebrew and Scoop
+
+Not available yet. Templates are in [`packaging/`](packaging/), waiting on a decision to create a Homebrew tap and a Scoop bucket.
+
+### Build a native binary yourself
+
+```bash
+dotnet publish src/Ytx -c Release -f net10.0 -r osx-arm64 -p:PublishAot=true -o out
+scripts/aot/smoke-test.sh out/ytx
+```
+
+Use your platform's runtime identifier (`osx-x64`, `linux-x64`, `linux-arm64`, `win-x64`). NativeAOT cannot cross-compile between operating systems, and it needs the platform's C toolchain (Xcode command-line tools, `clang` and `zlib1g-dev` on Linux, or Visual Studio's C++ workload on Windows).
+
## Output Format
Every document has the same keys in the same order. Fields that YouTube does not provide are `null`, never missing.
@@ -195,6 +304,10 @@ dotnet tool install -g solrevdev.ytx --add-source ./nupkg
โโโ assets/icon.svg # Package icon source (icon.png is rendered from it)
โโโ CHANGELOG.md # Release history
โโโ docs/investigations/ # Write-ups of reviews and investigations
+โโโ .github/workflows/native.yml # NativeAOT binaries per platform, attached to releases
+โโโ scripts/aot/ # Native publish, benchmark and smoke-test scripts
+โโโ benchmarks/Ytx.Benchmarks/ # BenchmarkDotNet project (not built by CI)
+โโโ packaging/ # Homebrew and Scoop templates
โโโ README.md # User and maintainer documentation
```
diff --git a/benchmarks/Ytx.Benchmarks/.gitignore b/benchmarks/Ytx.Benchmarks/.gitignore
new file mode 100644
index 0000000..21ffce0
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/.gitignore
@@ -0,0 +1,6 @@
+# Captured YouTube captions are third-party content. Never commit them.
+data/
+
+BenchmarkDotNet.Artifacts/
+bin/
+obj/
diff --git a/benchmarks/Ytx.Benchmarks/Benchmarks.cs b/benchmarks/Ytx.Benchmarks/Benchmarks.cs
new file mode 100644
index 0000000..e2d948c
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/Benchmarks.cs
@@ -0,0 +1,91 @@
+using BenchmarkDotNet.Attributes;
+using Ytx.Benchmarks.Subjects;
+
+namespace Ytx.Benchmarks;
+
+/// Datasets are discovered at run time: data/synthetic/*.json plus anything in data/captured/.
+public abstract class DatasetBench
+{
+ public static IEnumerable Datasets()
+ {
+ var dir = DataDir.Current;
+ return DataDir.AllDatasets(dir).Select(f => Path.GetRelativePath(dir, f).Replace('\\', '/'));
+ }
+
+ [ParamsSource(nameof(Datasets))]
+ public string Source { get; set; } = "";
+
+ protected Dataset Data = null!;
+
+ [GlobalSetup]
+ public void Setup() => Data = Dataset.Load(Path.Combine(DataDir.Current, Source));
+}
+
+/// Caption formatting only: normalise, build the raw and Markdown strings.
+[MemoryDiagnoser]
+public class FormatBench : DatasetBench
+{
+ [Benchmark(Baseline = true)]
+ public object Baseline_Format() => Baseline.Format(Data.Id, Data.Captions);
+
+ [Benchmark]
+ public object Tier1_Format() => Tier1.Format(Data.Id, Data.Captions);
+
+ [Benchmark]
+ public int Tier2_Format()
+ {
+ var (raw, md) = Tier2.Format(Data.Id, Data.Captions);
+ int n = raw.Length + md.Length;
+ raw.Dispose();
+ md.Dispose();
+ return n;
+ }
+}
+
+/// Everything ytx does after the network: format, serialise and write (to Stream.Null).
+[MemoryDiagnoser]
+public class EndToEndBench : DatasetBench
+{
+ [Benchmark(Baseline = true)]
+ public void Baseline_All() => Variants.Run(Variants.Baseline, Data, false, Stream.Null);
+
+ [Benchmark]
+ public void Tier1_All() => Variants.Run(Variants.Tier1, Data, false, Stream.Null);
+
+ [Benchmark]
+ public void Tier1SourceGen_All() => Variants.Run(Variants.Tier1SourceGen, Data, false, Stream.Null);
+
+ [Benchmark]
+ public void Tier2_All() => Variants.Run(Variants.Tier2, Data, false, Stream.Null);
+}
+
+/// NormalizeCaption over every cue of the largest dataset.
+[MemoryDiagnoser]
+public class NormalizeBench
+{
+ string[] _texts = [];
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ var largest = DataDir.AllDatasets(DataDir.Current).MaxBy(f => new FileInfo(f).Length)
+ ?? throw new InvalidOperationException("No datasets found. Run the 'generate' command first.");
+ _texts = Dataset.Load(largest).Captions.Select(c => c.Text).ToArray();
+ }
+
+ [Benchmark(Baseline = true)]
+ public int Baseline_Normalize()
+ {
+ int n = 0;
+ foreach (var t in _texts) n += Baseline.NormalizeCaption(t).Length;
+ return n;
+ }
+
+ [Benchmark]
+ public int Tier1_Normalize()
+ {
+ int n = 0;
+ foreach (var t in _texts) n += Tier1.NormalizeCaption(t).Length;
+ return n;
+ }
+}
diff --git a/benchmarks/Ytx.Benchmarks/Capture.cs b/benchmarks/Ytx.Benchmarks/Capture.cs
new file mode 100644
index 0000000..de761bb
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/Capture.cs
@@ -0,0 +1,88 @@
+using System.Diagnostics;
+using YoutubeExplode;
+using YoutubeExplode.Videos;
+
+namespace Ytx.Benchmarks;
+
+///
+/// Fetches a video's metadata and caption track once and saves it as a dataset under data/captured/.
+/// That folder is gitignored: captions are third-party content and must not be committed.
+/// It also prints how long each network phase took and how much it allocated.
+///
+public static class Capture
+{
+ public static async Task RunAsync(string dataDir, IReadOnlyList inputs, string language)
+ {
+ var outDir = Path.Combine(dataDir, "captured");
+ Directory.CreateDirectory(outDir);
+ var client = new YoutubeClient();
+ int failures = 0;
+
+ foreach (var input in inputs)
+ {
+ var id = VideoId.TryParse(input);
+ if (id == null)
+ {
+ Console.Error.WriteLine($"skip '{input}': not a YouTube URL or video ID");
+ failures++;
+ continue;
+ }
+
+ try
+ {
+ var sw = Stopwatch.StartNew();
+ long a0 = GC.GetTotalAllocatedBytes(true);
+ var video = await client.Videos.GetAsync(id.Value);
+ var t1 = sw.Elapsed;
+ long a1 = GC.GetTotalAllocatedBytes(true);
+
+ var manifest = await client.Videos.ClosedCaptions.GetManifestAsync(video.Id);
+ var t2 = sw.Elapsed;
+ long a2 = GC.GetTotalAllocatedBytes(true);
+
+ // Same ordering as ytx v1.0.7, so the benchmark formats the track ytx would have chosen.
+ // (Issue #3 bug 1: this prefers auto-generated tracks. Fix it here when ytx fixes it.)
+ var track = manifest.Tracks
+ .OrderByDescending(t => LanguageMatches(t.Language.Code, t.Language.Name, language))
+ .ThenByDescending(t => t.IsAutoGenerated)
+ .FirstOrDefault();
+ if (track == null)
+ {
+ Console.Error.WriteLine($"skip {id}: no caption tracks");
+ failures++;
+ continue;
+ }
+
+ var captions = await client.Videos.ClosedCaptions.GetAsync(track);
+ var t3 = sw.Elapsed;
+ long a3 = GC.GetTotalAllocatedBytes(true);
+
+ var d = new Dataset(
+ video.Id.Value, input, video.Title ?? "", video.Description ?? "",
+ captions.Captions.Select(c => new Cap(c.Text, c.Offset, c.Duration)).ToArray());
+ var path = Path.Combine(outDir, video.Id.Value + ".json");
+ d.Save(path);
+
+ Console.Error.WriteLine(
+ $"{id}: video {t1.TotalMilliseconds:F0} ms / {(a1 - a0) / 1048576.0:F1} MB, " +
+ $"manifest {(t2 - t1).TotalMilliseconds:F0} ms / {(a2 - a1) / 1048576.0:F1} MB, " +
+ $"captions {(t3 - t2).TotalMilliseconds:F0} ms / {(a3 - a2) / 1048576.0:F1} MB, " +
+ $"track '{track.Language.Name}' auto={track.IsAutoGenerated} cues={d.Captions.Length}");
+ Console.WriteLine(path);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"failed {id}: {ex.Message}");
+ failures++;
+ }
+ }
+
+ return failures == 0 ? 0 : 1;
+ }
+
+ // Copy of Program.LanguageMatches in ytx v1.0.7.
+ static bool LanguageMatches(string code, string name, string preference) =>
+ code.Equals(preference, StringComparison.OrdinalIgnoreCase)
+ || name.Equals(preference, StringComparison.OrdinalIgnoreCase)
+ || name.Contains(preference, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/benchmarks/Ytx.Benchmarks/Dataset.cs b/benchmarks/Ytx.Benchmarks/Dataset.cs
new file mode 100644
index 0000000..9f61971
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/Dataset.cs
@@ -0,0 +1,120 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Ytx.Benchmarks;
+
+public sealed record Cap(string Text, TimeSpan Offset, TimeSpan Duration);
+
+// Same shape and property names as the Output class in src/Ytx/Program.cs (v1.0.7).
+public class Output
+{
+ public string url { get; set; } = "";
+ public string title { get; set; } = "";
+ public string description { get; set; } = "";
+ public string transcriptRaw { get; set; } = "";
+ public string transcript { get; set; } = "";
+}
+
+[JsonSerializable(typeof(Output))]
+public partial class OutputContext : JsonSerializerContext { }
+
+///
+/// One video's metadata and caption track, as captured from YouTube or generated synthetically.
+/// File format: {"id","url","title","description","captions":[{"text","offsetTicks","durationTicks"}]}.
+///
+public sealed record Dataset(string Id, string Url, string Title, string Description, Cap[] Captions)
+{
+ public static Dataset Load(string path)
+ {
+ using var doc = JsonDocument.Parse(File.ReadAllBytes(path));
+ var r = doc.RootElement;
+ var caps = r.GetProperty("captions").EnumerateArray()
+ .Select(c => new Cap(
+ c.GetProperty("text").GetString()!,
+ TimeSpan.FromTicks(c.GetProperty("offsetTicks").GetInt64()),
+ c.TryGetProperty("durationTicks", out var d) ? TimeSpan.FromTicks(d.GetInt64()) : TimeSpan.Zero))
+ .ToArray();
+ return new Dataset(
+ r.GetProperty("id").GetString()!,
+ r.GetProperty("url").GetString()!,
+ r.GetProperty("title").GetString()!,
+ r.GetProperty("description").GetString()!,
+ caps);
+ }
+
+ public void Save(string path)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
+ using var fs = File.Create(path);
+ using var w = new Utf8JsonWriter(fs);
+ w.WriteStartObject();
+ w.WriteString("id", Id);
+ w.WriteString("url", Url);
+ w.WriteString("title", Title);
+ w.WriteString("description", Description);
+ w.WriteStartArray("captions");
+ foreach (var c in Captions)
+ {
+ w.WriteStartObject();
+ w.WriteString("text", c.Text);
+ w.WriteNumber("offsetTicks", c.Offset.Ticks);
+ w.WriteNumber("durationTicks", c.Duration.Ticks);
+ w.WriteEndObject();
+ }
+ w.WriteEndArray();
+ w.WriteEndObject();
+ }
+
+ /// Adds captions that exercise every normalisation edge case the variants must agree on.
+ public Dataset WithEdgeCases() => this with
+ {
+ Captions =
+ [
+ .. Captions,
+ new Cap(" a\t\tb ", TimeSpan.FromHours(101.5), TimeSpan.Zero),
+ new Cap(" ", TimeSpan.Zero, TimeSpan.Zero),
+ new Cap("x\u00A0\u2028y\u0085 z\u3000", TimeSpan.FromSeconds(59.9), TimeSpan.Zero),
+ new Cap("caf\u00E9 \U0001F600 na\u00EFve", TimeSpan.FromSeconds(3599.99), TimeSpan.Zero),
+ new Cap("\n", TimeSpan.FromSeconds(1), TimeSpan.Zero),
+ ],
+ };
+}
+
+public static class DataDir
+{
+ public const string EnvVar = "YTX_BENCH_DATA";
+
+ public static bool IsDefault { get; private set; }
+
+ ///
+ /// Resolves the data folder once and exports it as an absolute path, because BenchmarkDotNet
+ /// runs each benchmark in a child process with a different working directory.
+ ///
+ public static string Resolve(string? overridePath = null)
+ {
+ var dir = overridePath ?? Environment.GetEnvironmentVariable(EnvVar);
+ IsDefault = dir == null;
+ dir ??= FindDefault();
+ dir = Path.GetFullPath(dir);
+ Environment.SetEnvironmentVariable(EnvVar, dir);
+ return dir;
+ }
+
+ public static string Current => Environment.GetEnvironmentVariable(EnvVar) ?? Resolve();
+
+ public static IEnumerable AllDatasets(string dir) =>
+ Directory.Exists(dir)
+ ? Directory.EnumerateFiles(dir, "*.json", SearchOption.AllDirectories).Order(StringComparer.Ordinal)
+ : [];
+
+ // Walk up from the build output to the folder that holds the .csproj, then use its data/ folder.
+ static string FindDefault()
+ {
+ for (var d = new DirectoryInfo(AppContext.BaseDirectory); d != null; d = d.Parent)
+ {
+ if (File.Exists(Path.Combine(d.FullName, "Ytx.Benchmarks.csproj")))
+ return Path.Combine(d.FullName, "data");
+ }
+ return Path.Combine(Directory.GetCurrentDirectory(), "data");
+ }
+}
diff --git a/benchmarks/Ytx.Benchmarks/Program.cs b/benchmarks/Ytx.Benchmarks/Program.cs
new file mode 100644
index 0000000..40922b0
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/Program.cs
@@ -0,0 +1,128 @@
+using System.Diagnostics;
+using BenchmarkDotNet.Running;
+
+namespace Ytx.Benchmarks;
+
+public static class Program
+{
+ const string Usage = """
+ Ytx.Benchmarks: measure and verify ytx's caption formatting and JSON output.
+
+ Usage:
+ dotnet run -c Release -- generate [--seed N] Write synthetic datasets to data/synthetic/
+ dotnet run -c Release -- capture [-l LANG] ID_OR_URL... Fetch real captions into data/captured/ (gitignored)
+ dotnet run -c Release -- verify Check every variant is byte-identical to Baseline
+ dotnet run -c Release -- replay VARIANT FILE One cold run in this process (pipe stdout to /dev/null)
+ dotnet run -c Release -- [BenchmarkDotNet args] Run benchmarks, e.g. --filter '*EndToEnd*'
+
+ Options:
+ --data DIR Dataset folder (default: data/ next to the .csproj, or $YTX_BENCH_DATA)
+
+ Variants: baseline, tier1, tier1sg, tier2
+ """;
+
+ public static async Task Main(string[] args)
+ {
+ var list = args.ToList();
+ string? dataOverride = TakeOption(list, "--data");
+ var dataDir = DataDir.Resolve(dataOverride);
+ var command = list.Count > 0 ? list[0] : "";
+
+ switch (command)
+ {
+ case "-h" or "--help" or "help":
+ Console.WriteLine(Usage);
+ return 0;
+
+ case "generate":
+ {
+ var seed = TakeOption(list, "--seed");
+ return Synthetic.WriteAll(dataDir, seed == null ? 42 : int.Parse(seed));
+ }
+
+ case "capture":
+ {
+ var language = TakeOption(list, "-l") ?? TakeOption(list, "--language") ?? "English";
+ if (list.Count < 2)
+ {
+ Console.Error.WriteLine("capture needs at least one video ID or URL.");
+ return 2;
+ }
+ return await Capture.RunAsync(dataDir, list.Skip(1).ToList(), language);
+ }
+
+ case "verify":
+ Synthetic.EnsureExists(dataDir);
+ return Verify(dataDir);
+
+ case "replay":
+ if (list.Count < 3)
+ {
+ Console.Error.WriteLine("replay needs VARIANT and FILE.");
+ return 2;
+ }
+ return Replay(list[1], list[2]);
+
+ default:
+ Synthetic.EnsureExists(dataDir);
+ BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(list.ToArray());
+ return 0;
+ }
+ }
+
+ ///
+ /// Every variant must produce exactly the bytes v1.0.7 would, for every dataset, with and without
+ /// the edge-case captions, in indented and compact mode. Returns the number of mismatches.
+ ///
+ static int Verify(string dataDir)
+ {
+ int bad = 0, checks = 0;
+ foreach (var file in DataDir.AllDatasets(dataDir))
+ {
+ var original = Dataset.Load(file);
+ var name = Path.GetRelativePath(dataDir, file);
+ foreach (var d in new[] { original, original.WithEdgeCases() })
+ foreach (var compact in new[] { false, true })
+ {
+ var expected = Variants.RunToBytes(Variants.Baseline, d, compact);
+ foreach (var v in Variants.All.Where(v => v != Variants.Baseline))
+ {
+ var actual = Variants.RunToBytes(v, d, compact);
+ bool same = expected.AsSpan().SequenceEqual(actual);
+ checks++;
+ if (!same) bad++;
+ Console.WriteLine($"{(same ? "IDENTICAL" : "DIFF ")} {v,-8} {name} cues={d.Captions.Length} compact={compact} bytes={expected.Length}{(same ? "" : $" vs {actual.Length}")}");
+ }
+ }
+ }
+
+ Console.WriteLine(bad == 0 ? $"OK: {checks} checks, all identical." : $"FAILED: {bad} of {checks} checks differ.");
+ return bad == 0 ? 0 : 1;
+ }
+
+ // A cold, one-shot run in a fresh process: includes JIT and serialiser warm-up, which is what a CLI pays.
+ static int Replay(string variant, string file)
+ {
+ var d = Dataset.Load(file);
+ var sw = Stopwatch.StartNew();
+ long before = GC.GetTotalAllocatedBytes(true);
+ using (var stdout = Console.OpenStandardOutput())
+ {
+ Variants.Run(variant, d, false, stdout);
+ }
+ long after = GC.GetTotalAllocatedBytes(true);
+ Console.Error.WriteLine(
+ $"{variant}\t{sw.Elapsed.TotalMilliseconds:F1} ms\t{(after - before) / 1048576.0:F2} MB\t" +
+ $"gen0={GC.CollectionCount(0)} gen1={GC.CollectionCount(1)} gen2={GC.CollectionCount(2)}");
+ return 0;
+ }
+
+ static string? TakeOption(List args, string name)
+ {
+ int i = args.IndexOf(name);
+ if (i < 0 || i + 1 >= args.Count) return null;
+ var value = args[i + 1];
+ args.RemoveRange(i, 2);
+ return value;
+ }
+}
diff --git a/benchmarks/Ytx.Benchmarks/README.md b/benchmarks/Ytx.Benchmarks/README.md
new file mode 100644
index 0000000..a782907
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/README.md
@@ -0,0 +1,97 @@
+# Ytx.Benchmarks
+
+BenchmarkDotNet benchmarks and a byte-for-byte verification harness for the part of ytx that runs
+after the network: caption normalisation, transcript formatting, JSON serialisation and output.
+The results are written up in
+[`docs/investigations/2026-10-native-aot-and-allocations.md`](../../docs/investigations/2026-10-native-aot-and-allocations.md).
+
+This project is standalone. `src/Ytx` and `tests/Ytx.Tests` do not reference it, the repository has
+no solution file, and CI builds, tests and packs those two projects by path. So it never runs in
+CI and never ends up in the NuGet package. It is also marked `IsPackable=false`.
+
+## What is measured
+
+Each benchmark subject is a copy of the code, so the numbers stay comparable as `src/Ytx` changes.
+
+| Subject | File | Description |
+|---|---|---|
+| Baseline | `Subjects/Baseline.cs` | ytx v1.0.7 verbatim: `Regex.Replace` per caption, interpolated strings, serialise to a string, `Console.WriteLine` |
+| Tier 1 | `Subjects/Tier1.cs` | Skip the regex for clean captions, format numbers into the `StringBuilder`, serialise straight to the stream. `tier1sg` uses a source-generated `JsonSerializerContext`. |
+| Tier 2 | `Subjects/Tier2.cs` | Pooled buffers, hand-written digit formatting and a chunked `Utf8JsonWriter`. A measuring stick, not a recommendation. |
+
+Benchmark classes (in `Benchmarks.cs`):
+
+- `NormalizeBench`: `NormalizeCaption` over every cue of the largest dataset.
+- `FormatBench`: building the raw and Markdown transcript strings.
+- `EndToEndBench`: format, serialise and write to `Stream.Null`.
+
+## Data
+
+The repository contains **no real caption data**. YouTube captions belong to the video owners.
+
+- `generate` writes two synthetic datasets to `data/synthetic/`. They have the same shape as real
+ auto-generated captions: the same cue counts as the 17-minute and 5-hour videos used in the
+ write-up, alternating text and `"\n"` filler cues, about 35 characters per text cue, and emoji and
+ accents in the description. The words are nonsense, generated from a fixed seed.
+- `capture` fetches a real video's metadata and caption track into `data/captured/`. It picks the
+ track the same way v1.0.7 does.
+- `data/` is gitignored. Do not commit captured files.
+
+Benchmarks and `verify` create the synthetic data automatically if it is missing. Every `*.json`
+under `data/` becomes a benchmark parameter.
+
+## Run it
+
+From this folder:
+
+```bash
+dotnet build -c Release
+
+# Optional: write the synthetic datasets explicitly (benchmarks do this on first run)
+dotnet run -c Release -- generate
+
+# Optional: add real captions for local runs only
+dotnet run -c Release -- capture aCi6BhrvjhM NYFGCESmikA
+dotnet run -c Release -- capture -l fr "https://www.youtube.com/watch?v=VIDEO_ID"
+
+# Check every variant produces exactly the bytes v1.0.7 would
+dotnet run -c Release -- verify
+
+# Run the benchmarks (BenchmarkDotNet filters and options pass through)
+dotnet run -c Release -- --filter '*'
+dotnet run -c Release -- --filter '*EndToEnd*' --job short
+
+# One cold run in a fresh process, the way a CLI pays for it (JIT and serialiser warm-up included)
+for v in baseline tier1 tier1sg tier2; do
+ bin/Release/net10.0/Ytx.Benchmarks replay $v data/synthetic/long.json > /dev/null
+done
+```
+
+`--data DIR` (or `YTX_BENCH_DATA`) points every command at another dataset folder. A folder given
+that way is used as is: synthetic files are only generated in the default `data/` folder.
+
+## Verify mode
+
+`verify` runs every variant against Baseline for each dataset, with and without extra edge-case
+captions (` `, NBSP, U+2028, U+0085, U+3000, emoji, an offset over 100 hours), in indented and
+compact mode. It prints `IDENTICAL` or `DIFF` per check and exits non-zero on any difference.
+It is the starting point for a golden-output test in `tests/Ytx.Tests`.
+
+Windows note: v1.0.7 builds the Markdown transcript with `AppendLine`, so on Windows the lines are
+separated by `\r\n`, and `Console.WriteLine` ends the output with `\r\n`. The variants here copy
+that with `Environment.NewLine`. If ytx switches to a fixed `\n`, update Baseline's expectations.
+
+## Results from October 2026
+
+Apple M1, macOS 27.0.1, .NET SDK 10.0.401, runtime 10.0.12, BenchmarkDotNet 0.15.2, real captions.
+See the write-up for the full table and the cold-run numbers.
+
+| Method | Video | Mean | Allocated |
+|---|---|---:|---:|
+| Baseline_All | 5 h 15 min | 7,947 ยตs | 10,506,442 B |
+| Tier1_All | 5 h 15 min | 3,974 ยตs | 4,469,011 B |
+| Tier1SourceGen_All | 5 h 15 min | 3,923 ยตs | 4,469,010 B |
+| Tier2_All | 5 h 15 min | 3,440 ยตs | 58,224 B |
+
+The synthetic `long` dataset gives numbers within a few percent of these (Baseline_All 7.7 ms and
+10.9 MB on a short job), so it is a fair offline stand-in.
diff --git a/benchmarks/Ytx.Benchmarks/Subjects/Baseline.cs b/benchmarks/Ytx.Benchmarks/Subjects/Baseline.cs
new file mode 100644
index 0000000..49136f0
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/Subjects/Baseline.cs
@@ -0,0 +1,64 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace Ytx.Benchmarks.Subjects;
+
+///
+/// The formatting and output code from ytx v1.0.7 (commit 07d0e12, src/Ytx/Program.cs), kept as the
+/// reference every other variant must match byte for byte. Do not "improve" this file.
+/// The only change is that output goes to a supplied stream instead of Console.Out.
+///
+public static class Baseline
+{
+ public static (string Raw, string Markdown) Format(string videoId, IReadOnlyList captions)
+ {
+ var rawSb = new StringBuilder();
+ var mdSb = new StringBuilder();
+
+ foreach (var caption in captions)
+ {
+ var text = NormalizeCaption(caption.Text);
+ if (string.IsNullOrWhiteSpace(text)) continue;
+
+ if (rawSb.Length > 0) rawSb.Append(' ');
+ rawSb.Append(text);
+
+ var timestamp = ToHhMmSs(caption.Offset);
+ var link = $"https://www.youtube.com/watch?v={videoId}&t={(int)caption.Offset.TotalSeconds}s";
+ mdSb.AppendLine($"- [{timestamp}]({link}) {text}");
+ }
+
+ return (rawSb.ToString().Trim(), mdSb.ToString().TrimEnd());
+ }
+
+ /// Mirrors JsonSerializer.Serialize to a string followed by Console.WriteLine.
+ public static void Write(Stream stdout, Output output, bool compact)
+ {
+ var json = JsonSerializer.Serialize(output, new JsonSerializerOptions
+ {
+ WriteIndented = !compact,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ });
+
+ // Console.OutputEncoding = UTF8 writes no BOM, and WriteLine appends Environment.NewLine.
+ using var writer = new StreamWriter(stdout, new UTF8Encoding(false), leaveOpen: true);
+ writer.WriteLine(json);
+ writer.Flush();
+ }
+
+ public static string ToHhMmSs(TimeSpan ts)
+ {
+ int h = (int)ts.TotalHours;
+ int m = ts.Minutes;
+ int s = ts.Seconds;
+ return h > 0 ? $"{h:00}:{m:00}:{s:00}" : $"{m:00}:{s:00}";
+ }
+
+ public static string NormalizeCaption(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return "";
+ text = Regex.Replace(text, @"\s+", " ").Trim();
+ return text.Replace(" ", " ");
+ }
+}
diff --git a/benchmarks/Ytx.Benchmarks/Subjects/Tier1.cs b/benchmarks/Ytx.Benchmarks/Subjects/Tier1.cs
new file mode 100644
index 0000000..0e99501
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/Subjects/Tier1.cs
@@ -0,0 +1,98 @@
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+
+namespace Ytx.Benchmarks.Subjects;
+
+///
+/// Tier 1: small, readable changes that keep the output byte-identical to v1.0.7.
+/// 1. Skip the regex when a caption is already in normal form.
+/// 2. Format numbers straight into the StringBuilder instead of building temporary strings.
+/// 3. Serialise straight to the output stream (reflection or source-generated metadata).
+///
+public static partial class Tier1
+{
+ [GeneratedRegex(@"\s+")]
+ private static partial Regex Whitespace();
+
+ public static (string Raw, string Markdown) Format(string videoId, IReadOnlyList captions)
+ {
+ var rawSb = new StringBuilder();
+ var mdSb = new StringBuilder();
+
+ foreach (var caption in captions)
+ {
+ var text = NormalizeCaption(caption.Text);
+ if (string.IsNullOrWhiteSpace(text)) continue;
+
+ // v1.0.7 used AppendLine then TrimEnd, so the separator is Environment.NewLine.
+ if (rawSb.Length > 0)
+ {
+ rawSb.Append(' ');
+ mdSb.Append(Environment.NewLine);
+ }
+ rawSb.Append(text);
+
+ // The StringBuilder interpolation handler formats the numbers in place.
+ var ts = caption.Offset;
+ int h = (int)ts.TotalHours;
+ if (h > 0) mdSb.Append($"- [{h:00}:{ts.Minutes:00}:{ts.Seconds:00}");
+ else mdSb.Append($"- [{ts.Minutes:00}:{ts.Seconds:00}");
+ mdSb.Append($"](https://www.youtube.com/watch?v={videoId}&t={(int)ts.TotalSeconds}s) ").Append(text);
+ }
+
+ return (rawSb.ToString().Trim(), mdSb.ToString().TrimEnd());
+ }
+
+ public static string NormalizeCaption(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return "";
+ // \s+ matches every ordinary single space, so Regex.Replace returns a new string for nearly
+ // every caption even when nothing changes. Only run it when the text is not already normal.
+ if (NeedsNormalizing(text)) text = Whitespace().Replace(text, " ").Trim();
+ return text.Contains(" ") ? text.Replace(" ", " ") : text;
+ }
+
+ static bool NeedsNormalizing(ReadOnlySpan t)
+ {
+ if (char.IsWhiteSpace(t[0]) || char.IsWhiteSpace(t[^1])) return true;
+ for (int i = 1; i < t.Length; i++)
+ {
+ char c = t[i];
+ if (c == ' ')
+ {
+ if (t[i - 1] == ' ') return true;
+ }
+ else if (char.IsWhiteSpace(c))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ static readonly byte[] NewLine = Encoding.UTF8.GetBytes(Environment.NewLine);
+
+ static readonly JsonSerializerOptions Indented = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+ static readonly JsonSerializerOptions Compact = new() { WriteIndented = false, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+
+ /// Reflection-based serialiser, written as UTF-8 to the stream with no intermediate string.
+ public static void Write(Stream stdout, Output output, bool compact)
+ {
+ JsonSerializer.Serialize(stdout, output, compact ? Compact : Indented);
+ stdout.Write(NewLine);
+ stdout.Flush();
+ }
+
+ static readonly OutputContext IndentedContext = new(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
+ static readonly OutputContext CompactContext = new(new JsonSerializerOptions { WriteIndented = false, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
+
+ /// Source-generated metadata: trim and AOT safe, and skips reflection warm-up.
+ public static void WriteSourceGen(Stream stdout, Output output, bool compact)
+ {
+ JsonSerializer.Serialize(stdout, output, (compact ? CompactContext : IndentedContext).Output);
+ stdout.Write(NewLine);
+ stdout.Flush();
+ }
+}
diff --git a/benchmarks/Ytx.Benchmarks/Subjects/Tier2.cs b/benchmarks/Ytx.Benchmarks/Subjects/Tier2.cs
new file mode 100644
index 0000000..a729151
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/Subjects/Tier2.cs
@@ -0,0 +1,198 @@
+using System.Buffers;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+
+namespace Ytx.Benchmarks.Subjects;
+
+///
+/// Tier 2: pooled buffers, hand-written digit formatting and a chunked Utf8JsonWriter.
+/// Kept as a measuring stick for "how far can this go". The write-up recommends against shipping it:
+/// it saves about 3 ms more than Tier 1 on a 5-hour video and is much harder to keep correct.
+///
+public static class Tier2
+{
+ /// A growable pooled char buffer (a minimal ValueStringBuilder that can live on the heap).
+ public sealed class CharBuffer : IDisposable
+ {
+ char[] _array;
+ int _length;
+
+ public CharBuffer(int capacity) => _array = ArrayPool.Shared.Rent(capacity);
+
+ public int Length => _length;
+ public ReadOnlySpan Span => _array.AsSpan(0, _length);
+
+ Span Reserve(int needed)
+ {
+ if (_length + needed > _array.Length)
+ {
+ var bigger = ArrayPool.Shared.Rent(Math.Max(_array.Length * 2, _length + needed));
+ _array.AsSpan(0, _length).CopyTo(bigger);
+ ArrayPool.Shared.Return(_array);
+ _array = bigger;
+ }
+ return _array.AsSpan(_length);
+ }
+
+ public void Append(char c)
+ {
+ Reserve(1)[0] = c;
+ _length++;
+ }
+
+ public void Append(ReadOnlySpan s)
+ {
+ s.CopyTo(Reserve(s.Length));
+ _length += s.Length;
+ }
+
+ public void AppendTwoDigits(int v)
+ {
+ var d = Reserve(2);
+ d[0] = (char)('0' + v / 10);
+ d[1] = (char)('0' + v % 10);
+ _length += 2;
+ }
+
+ public void AppendInt(int v)
+ {
+ v.TryFormat(Reserve(11), out int written);
+ _length += written;
+ }
+
+ // Matches the "00" format specifier: at least two digits, more if needed (hours above 99).
+ public void AppendPadded(int v)
+ {
+ if (v < 100) AppendTwoDigits(v);
+ else AppendInt(v);
+ }
+
+ public void Dispose()
+ {
+ ArrayPool.Shared.Return(_array);
+ _array = [];
+ }
+ }
+
+ // Collapse runs of whitespace to one space and trim both ends, writing into dst. Returns the length.
+ static int Normalize(ReadOnlySpan src, Span dst)
+ {
+ int n = 0;
+ bool pendingSpace = false;
+ foreach (var c in src)
+ {
+ if (char.IsWhiteSpace(c))
+ {
+ pendingSpace = n > 0;
+ continue;
+ }
+ if (pendingSpace)
+ {
+ dst[n++] = ' ';
+ pendingSpace = false;
+ }
+ dst[n++] = c;
+ }
+ return n;
+ }
+
+ public static (CharBuffer Raw, CharBuffer Markdown) Format(string videoId, IReadOnlyList captions)
+ {
+ int estimate = 0;
+ foreach (var c in captions) estimate += c.Text.Length;
+
+ var raw = new CharBuffer(estimate + captions.Count);
+ var md = new CharBuffer(estimate + captions.Count * (60 + videoId.Length));
+ char[] scratch = ArrayPool.Shared.Rent(256);
+ var newLine = Environment.NewLine.AsSpan();
+
+ foreach (var caption in captions)
+ {
+ var src = caption.Text.AsSpan();
+ if (src.Length > scratch.Length)
+ {
+ ArrayPool.Shared.Return(scratch);
+ scratch = ArrayPool.Shared.Rent(src.Length);
+ }
+
+ ReadOnlySpan text = scratch.AsSpan(0, Normalize(src, scratch));
+ // Trap: v1.0.7 replaces after collapsing whitespace, so "a b" keeps three spaces.
+ // Fall back to the legacy code for that rare case instead of re-implementing the quirk.
+ if (text.IndexOf(" ") >= 0) text = Baseline.NormalizeCaption(caption.Text);
+ if (text.IsWhiteSpace()) continue;
+
+ if (raw.Length > 0)
+ {
+ raw.Append(' ');
+ md.Append(newLine);
+ }
+ raw.Append(text);
+
+ var ts = caption.Offset;
+ int h = (int)ts.TotalHours;
+ md.Append("- [");
+ if (h > 0)
+ {
+ md.AppendPadded(h);
+ md.Append(':');
+ }
+ md.AppendTwoDigits(ts.Minutes);
+ md.Append(':');
+ md.AppendTwoDigits(ts.Seconds);
+ md.Append("](https://www.youtube.com/watch?v=");
+ md.Append(videoId);
+ md.Append("&t=");
+ md.AppendInt((int)ts.TotalSeconds);
+ md.Append("s) ");
+ md.Append(text);
+ }
+
+ ArrayPool.Shared.Return(scratch);
+ return (raw, md);
+ }
+
+ static readonly JsonWriterOptions IndentedWriter = new() { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+ static readonly JsonWriterOptions CompactWriter = new() { Indented = false, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+ static readonly byte[] NewLine = Encoding.UTF8.GetBytes(Environment.NewLine);
+
+ // Writes a large string in 8K-char segments so the writer's buffer stays small.
+ static void WriteChunked(Utf8JsonWriter w, string name, ReadOnlySpan value)
+ {
+ w.WritePropertyName(name);
+ if (value.IsEmpty)
+ {
+ w.WriteStringValue(value);
+ return;
+ }
+
+ const int Chunk = 8192;
+ while (value.Length > 0)
+ {
+ int n = Math.Min(Chunk, value.Length);
+ // Trap: splitting a surrogate pair across segments changes the escaped output.
+ if (n < value.Length && char.IsHighSurrogate(value[n - 1])) n--;
+ w.WriteStringValueSegment(value[..n], isFinalSegment: n == value.Length);
+ value = value[n..];
+ w.Flush();
+ }
+ }
+
+ public static void Write(Stream stdout, string url, string title, string description,
+ ReadOnlySpan raw, ReadOnlySpan markdown, bool compact)
+ {
+ using (var w = new Utf8JsonWriter(stdout, compact ? CompactWriter : IndentedWriter))
+ {
+ w.WriteStartObject();
+ w.WriteString("url", url);
+ w.WriteString("title", title);
+ w.WriteString("description", description);
+ w.Flush();
+ WriteChunked(w, "transcriptRaw", raw.Trim());
+ WriteChunked(w, "transcript", markdown.TrimEnd());
+ w.WriteEndObject();
+ }
+ stdout.Write(NewLine);
+ stdout.Flush();
+ }
+}
diff --git a/benchmarks/Ytx.Benchmarks/Synthetic.cs b/benchmarks/Ytx.Benchmarks/Synthetic.cs
new file mode 100644
index 0000000..099cca5
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/Synthetic.cs
@@ -0,0 +1,104 @@
+using System.Text;
+
+namespace Ytx.Benchmarks;
+
+///
+/// Generates caption tracks shaped like real YouTube auto-generated captions, so the benchmarks run
+/// offline and the repository holds no third-party transcript text.
+///
+/// Shape measured from two real auto-caption tracks (17 min and 5 h 15 min):
+/// - Cues alternate between a text line and a cue whose text is a single "\n" (rolling captions).
+/// About half of all cues are that whitespace-only filler.
+/// - Text cues average 34-37 characters, about 7 words of about 4.3 characters, with no extra spaces.
+/// - A text cue starts about 10 ms after the previous filler cue. Durations are about 3-8 s.
+/// The words are nonsense built from a fixed syllable list, so the output is deterministic for a seed.
+///
+public static class Synthetic
+{
+ public sealed record Preset(string Name, string Id, int Cues, TimeSpan Length);
+
+ // Cue counts and lengths match the two videos used in the original measurements.
+ public static readonly Preset[] Presets =
+ [
+ new("short", "synthShort1", 924, TimeSpan.FromSeconds(1004)),
+ new("long", "synthLong01", 16284, TimeSpan.FromSeconds(18940)),
+ ];
+
+ static readonly string[] Syllables =
+ [
+ "ka", "lo", "ri", "ten", "mar", "so", "vel", "an", "di", "po", "ques", "tra", "mi", "nor", "el",
+ "ba", "sun", "ti", "ro", "fen", "da", "lu", "ger", "o", "is", "pa", "the", "wen", "at", "cy",
+ ];
+
+ public static Dataset Generate(Preset p, int seed = 42)
+ {
+ var rng = new Random(seed);
+ var caps = new List(p.Cues);
+ double step = p.Length.TotalSeconds / Math.Max(1, p.Cues / 2);
+ double t = 0.16;
+
+ for (int i = 0; i < p.Cues; i++)
+ {
+ if (i % 2 == 0)
+ {
+ caps.Add(new Cap(Line(rng), TimeSpan.FromSeconds(t), TimeSpan.FromSeconds(3 + rng.NextDouble() * 5)));
+ }
+ else
+ {
+ // Rolling-caption filler: whitespace only, normalised away by every variant.
+ double filler = t + step * (0.95 + rng.NextDouble() * 0.1);
+ caps.Add(new Cap("\n", TimeSpan.FromSeconds(filler), TimeSpan.FromSeconds(1.5 + rng.NextDouble() * 3)));
+ t = filler + 0.01;
+ }
+ }
+
+ var description = new StringBuilder();
+ for (int i = 0; i < 12; i++) description.Append(Line(rng)).Append(i % 3 == 2 ? "\n\n" : " ");
+ // Characters that exercise the relaxed encoder: emoji (surrogate pairs), accents, dashes, quotes.
+ description.Append("\U0001F916 caf\u00E9 \u2014 \"quoted\" & https://example.com/?a=1&b=2");
+
+ return new Dataset(
+ Id: p.Id,
+ Url: $"https://www.youtube.com/watch?v={p.Id}",
+ Title: $"Synthetic {p.Name} caption track ({p.Cues} cues)",
+ Description: description.ToString(),
+ Captions: caps.ToArray());
+ }
+
+ static string Line(Random rng)
+ {
+ var sb = new StringBuilder(40);
+ int words = 5 + rng.Next(5);
+ for (int w = 0; w < words; w++)
+ {
+ if (w > 0) sb.Append(' ');
+ int syllables = 1 + rng.Next(3);
+ for (int s = 0; s < syllables; s++) sb.Append(Syllables[rng.Next(Syllables.Length)]);
+ }
+ return sb.ToString();
+ }
+
+ public static int WriteAll(string dataDir, int seed)
+ {
+ var dir = Path.Combine(dataDir, "synthetic");
+ foreach (var p in Presets)
+ {
+ var path = Path.Combine(dir, p.Name + ".json");
+ var d = Generate(p, seed);
+ d.Save(path);
+ int text = d.Captions.Count(c => !string.IsNullOrWhiteSpace(c.Text));
+ Console.WriteLine($"wrote {path} cues={d.Captions.Length} text-cues={text} last={d.Captions[^1].Offset:hh\\:mm\\:ss}");
+ }
+ return 0;
+ }
+
+ ///
+ /// Creates the default synthetic files if any are missing. Only touches the default data folder:
+ /// a folder passed with --data or YTX_BENCH_DATA is used exactly as given.
+ ///
+ public static void EnsureExists(string dataDir)
+ {
+ if (DataDir.IsDefault && !Presets.All(p => File.Exists(Path.Combine(dataDir, "synthetic", p.Name + ".json"))))
+ WriteAll(dataDir, 42);
+ }
+}
diff --git a/benchmarks/Ytx.Benchmarks/Variants.cs b/benchmarks/Ytx.Benchmarks/Variants.cs
new file mode 100644
index 0000000..83cb40b
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/Variants.cs
@@ -0,0 +1,67 @@
+using Ytx.Benchmarks.Subjects;
+
+namespace Ytx.Benchmarks;
+
+/// Runs one variant end to end: format captions, build the output, serialise, write.
+public static class Variants
+{
+ public const string Baseline = "baseline";
+ public const string Tier1 = "tier1";
+ public const string Tier1SourceGen = "tier1sg";
+ public const string Tier2 = "tier2";
+
+ public static readonly string[] All = [Baseline, Tier1, Tier1SourceGen, Tier2];
+
+ public static void Run(string variant, Dataset d, bool compact, Stream sink)
+ {
+ switch (variant)
+ {
+ case Baseline:
+ {
+ var (raw, md) = Subjects.Baseline.Format(d.Id, d.Captions);
+ Subjects.Baseline.Write(sink, NewOutput(d, raw, md), compact);
+ break;
+ }
+ case Tier1:
+ {
+ var (raw, md) = Subjects.Tier1.Format(d.Id, d.Captions);
+ Subjects.Tier1.Write(sink, NewOutput(d, raw, md), compact);
+ break;
+ }
+ case Tier1SourceGen:
+ {
+ var (raw, md) = Subjects.Tier1.Format(d.Id, d.Captions);
+ Subjects.Tier1.WriteSourceGen(sink, NewOutput(d, raw, md), compact);
+ break;
+ }
+ case Tier2:
+ {
+ var (raw, md) = Subjects.Tier2.Format(d.Id, d.Captions);
+ using (raw)
+ using (md)
+ {
+ Subjects.Tier2.Write(sink, d.Url, d.Title, d.Description, raw.Span, md.Span, compact);
+ }
+ break;
+ }
+ default:
+ throw new ArgumentException($"Unknown variant '{variant}'. Expected one of: {string.Join(", ", All)}.");
+ }
+ }
+
+ public static byte[] RunToBytes(string variant, Dataset d, bool compact)
+ {
+ using var ms = new MemoryStream();
+ Run(variant, d, compact, ms);
+ return ms.ToArray();
+ }
+
+ static Output NewOutput(Dataset d, string raw, string md) => new()
+ {
+ url = d.Url,
+ title = d.Title,
+ description = d.Description,
+ transcriptRaw = raw,
+ transcript = md,
+ };
+}
diff --git a/benchmarks/Ytx.Benchmarks/Ytx.Benchmarks.csproj b/benchmarks/Ytx.Benchmarks/Ytx.Benchmarks.csproj
new file mode 100644
index 0000000..e01c1f0
--- /dev/null
+++ b/benchmarks/Ytx.Benchmarks/Ytx.Benchmarks.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ Ytx.Benchmarks
+ false
+ false
+
+ false
+
+
+
+
+
+
+
+
diff --git a/docs/investigations/2026-10-native-aot-and-allocations.md b/docs/investigations/2026-10-native-aot-and-allocations.md
new file mode 100644
index 0000000..605ff2a
--- /dev/null
+++ b/docs/investigations/2026-10-native-aot-and-allocations.md
@@ -0,0 +1,627 @@
+# NativeAOT and allocations in ytx
+
+- **Date:** October 2026
+- **Version studied:** ytx v1.0.7 (commit `07d0e12`)
+- **Status:** exploratory, not yet decided
+- **Tracking issue:** [#4](https://github.com/solrevdev/solrevdev.ytx/issues/4). Related review: [#3](https://github.com/solrevdev/solrevdev.ytx/issues/3).
+
+## The questions
+
+ytx is a small .NET global tool. It takes a YouTube URL, fetches the video's metadata and captions
+with [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode), and prints one JSON document.
+People run it by hand, but more and more it is run by agents and scripts, often many times in a row.
+
+Two questions came up:
+
+1. **Can ytx ship as a NativeAOT binary, and is it worth it?** A native binary needs no .NET runtime
+ and should start faster.
+2. **Is ytx wasting time or memory in its own code?** It builds large strings for long transcripts.
+ Would cutting allocations make it noticeably faster?
+
+## Short answers
+
+- **NativeAOT works after one small code change.** The JSON code must use a source-generated
+ `JsonSerializerContext`. With that, the AOT binary produced output byte-identical to v1.0.7.
+- **AOT starts in about 9 ms instead of about 38 ms.** It uses about 6x less CPU per real call and
+ half the peak memory. It needs no .NET runtime.
+- **A real call is about 90% network.** One person running ytx once will not notice AOT. Agents and
+ batch jobs that run it hundreds of times will, mostly through CPU and memory.
+- **Cutting allocations is not worth chasing for speed.** ytx's own code is about 2% of a run's time
+ and about 18% of its allocations. Two small, readable changes halve that cost. A full
+ zero-allocation rewrite is measurable in a benchmark but invisible to users.
+
+## Method
+
+### Environment
+
+| Item | Value |
+|---|---|
+| Machine | Apple M1, 8 cores, macOS 27.0.1 (build 26A434) |
+| .NET SDKs | 10.0.401 and 8.0.425 |
+| .NET runtime | 10.0.12 |
+| YoutubeExplode | 6.5.6 (as shipped in v1.0.7). 6.6.2 was also checked for allocations. |
+| BenchmarkDotNet | 0.15.2, default job, workstation GC |
+| Linux | `mcr.microsoft.com/dotnet/sdk:10.0-noble-aot` and `ubuntu:24.04` in Docker on the same Mac (arm64) |
+
+### Test videos
+
+- **`aCi6BhrvjhM`:** 17 minutes, 924 caption cues. Used for most network runs.
+- **`NYFGCESmikA`:** Lex Fridman #501, 5 hours 15 minutes, 16,284 caption cues, 1.1 MB of JSON output.
+ Used to stress the formatting code.
+
+Both are auto-generated caption tracks. About half of their cues are filler whose text is a single
+newline, which matters for the normalisation results below.
+
+### What was measured, and how
+
+- **Deployment shapes.** ytx was published six ways for `net10.0` and `osx-arm64` (see the table in
+ [Results](#results-net100-osx-arm64)). Each was timed as a separate process.
+- **Startup.** 30 runs after 3 warm-up runs, with no network: `--help`, `--version`, and stdin JSON
+ input that fails ID validation before any network call.
+- **Network runs.** 8 rounds against `aCi6BhrvjhM`. Each round runs every build once, in turn, so
+ network drift affects all builds equally. This was done twice.
+- **Resource use.** `/usr/bin/time -l` on one `--metadata-only` call per build, for peak memory and
+ instructions retired.
+- **Phases.** An instrumented copy of v1.0.7 printed the time and bytes allocated in each phase
+ (`GC.GetTotalAllocatedBytes`).
+- **Micro-benchmarks.** BenchmarkDotNet on the formatting and output code, replaying captured
+ captions so the network is excluded. Every variant was checked for byte-identical output.
+- **Cold runs.** Each formatting variant also ran once per fresh process, 5 times, because a CLI
+ pays JIT and warm-up costs that BenchmarkDotNet hides.
+
+### Limits
+
+- One machine. Windows and osx-x64 binaries were built but never run. linux-arm64 ran in Docker.
+- Network numbers come from one home connection in the UK. Their spread is large. Compare medians and
+ CPU time, not means.
+- Nothing was committed or pushed while measuring.
+
+## Part 1: NativeAOT
+
+### What breaks today
+
+Publishing v1.0.7 with `-p:PublishAot=true` succeeds, but with four warnings, all in `Program.cs`.
+YoutubeExplode and AngleSharp produce none. That was checked with `TrimmerSingleWarn=false`, which
+lists warnings per call site instead of one line per assembly. YoutubeExplode is marked `IsTrimmable`.
+
+The build-time analyzer warnings, verbatim:
+
+```text
+src/Ytx/Program.cs(88,33): warning IL2026: Using member 'System.Text.Json.JsonSerializer.Deserialize(String, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved. [src/Ytx/Ytx.csproj::TargetFramework=net10.0]
+src/Ytx/Program.cs(88,33): warning IL3050: Using member 'System.Text.Json.JsonSerializer.Deserialize(String, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications. [src/Ytx/Ytx.csproj::TargetFramework=net10.0]
+src/Ytx/Program.cs(176,24): warning IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved. [src/Ytx/Ytx.csproj::TargetFramework=net10.0]
+src/Ytx/Program.cs(176,24): warning IL3050: Using member 'System.Text.Json.JsonSerializer.Serialize(TValue, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications. [src/Ytx/Ytx.csproj::TargetFramework=net10.0]
+```
+
+The ILCompiler repeats them while generating native code:
+
+```text
+src/Ytx/Program.cs(88): Trim analysis warning IL2026: Program.d__1.MoveNext(): Using member 'System.Text.Json.JsonSerializer.Deserialize(String,JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. ...
+src/Ytx/Program.cs(88): AOT analysis warning IL3050: Program.d__1.MoveNext(): Using member 'System.Text.Json.JsonSerializer.Deserialize(String,JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. ...
+src/Ytx/Program.cs(176): Trim analysis warning IL2026: Program.d__1.MoveNext(): Using member 'System.Text.Json.JsonSerializer.Serialize