FFmpeg locator (TASK 4 ship step 2) + licensing: notices, About, LGPL-shared pin

IFfmpegLocator seam that resolves ffmpeg.exe (PATH -> cache -> pinned download),
plus the licensing compliance that makes a paid GA product defensible.

NOTE FOR USERS: this change shows NO difference in the app's behavior except a
new top-bar "About" button (opens THIRD-PARTY-NOTICES.txt). It is scaffolding
for the encoder/streaming work.

- Services/Encoder/: IFfmpegLocator + FfmpegLocator. Pin is BtbN lgpl-shared
  autobuild-2026-08-09-13-03 (NOT gyan.dev/GPL or static: LGPLv2.1 §6 static
  relink material avoided by dynamic linking); extracts ffmpeg.exe + libav*.dll
  via a staging dir so a crash never leaves a partial cache
- THIRD-PARTY-NOTICES.txt: LGPL/BSD/MIT notices + source offer, copied to the
  build output, surfaced by the About button; v1 gate = bundle full license
  texts (TASK 4 requirement 9)
- ai.md "Licensing - do not violate" guardrails (never GPL/nonfree/static/latest-
  tag, never link FFmpeg in, never drop notices); TASKS.md + Services/index.md
  updated
- Tests: FfmpegLocatorTests - hermetic decision-ladder integration test + shared-
  build DLL extraction + edge cases; docs updated (78 tests passing, 0 warnings)
This commit is contained in:
2026-08-10 11:02:26 -07:00
parent 18a21010bb
commit 8c7938aca0
10 changed files with 529 additions and 6 deletions
+2
View File
@@ -105,6 +105,8 @@
<!-- Single three-state action button --> <!-- Single three-state action button -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="About" Style="{StaticResource YtButtonSecondary}"
Click="AboutButton_Click" Margin="0,0,12,0" VerticalAlignment="Center"/>
<Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True" <Border Width="26" Height="26" CornerRadius="13" Background="#16213e" ClipToBounds="True"
Margin="0,0,8,0" VerticalAlignment="Center" Margin="0,0,8,0" VerticalAlignment="Center"
ToolTip="{Binding AccountDisplayName}" ToolTip="{Binding AccountDisplayName}"
+17
View File
@@ -1,5 +1,7 @@
using System.Collections; using System.Collections;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using System.Windows.Input; using System.Windows.Input;
@@ -123,6 +125,21 @@ public partial class MainWindow : Window
} }
} }
private void AboutButton_Click(object sender, RoutedEventArgs e)
{
// LGPL/BSD/MIT notices ship next to the exe; open in the OS text viewer.
var path = Path.Combine(AppContext.BaseDirectory, "THIRD-PARTY-NOTICES.txt");
if (!File.Exists(path)) return;
try
{
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
AppLog.Write(ex, "About: failed to open THIRD-PARTY-NOTICES.txt");
}
}
private void AddSourceButton_Click(object sender, RoutedEventArgs e) private void AddSourceButton_Click(object sender, RoutedEventArgs e)
{ {
if (sender is Button { ContextMenu: { } menu } button) if (sender is Button { ContextMenu: { } menu } button)
+129
View File
@@ -0,0 +1,129 @@
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using ytLive.Helpers;
namespace ytLive.Services.Encoder;
/// <summary>
/// The default <see cref="IFfmpegLocator"/>: probe PATH first (the user's own
/// install wins), then the cache in <c>%APPDATA%\ytLlive\tools</c>, then pull the
/// pinned BtbN **lgpl-shared** build (TASK 4 ship step 2). The shared variant is a
/// deliberate licensing choice: dynamic linking means LGPL compliance is "license
/// text + source offer", with no static-relink (LGPL §6) material required. The
/// shared zip puts <c>ffmpeg.exe</c> plus the <c>libav*.dll</c> family in <c>bin/</c>,
/// so both are extracted — Windows resolves the DLLs from the exe's own directory.
/// Search dirs, tools dir, and the downloader are constructor-injected so tests
/// fake the network and stay on a temp directory.
/// </summary>
public sealed class FfmpegLocator : IFfmpegLocator
{
public const string FileName = "ffmpeg.exe";
/// <summary>Pinned BtbN LGPL-shared win64 build (immutable autobuild tag; see TASKS.md).</summary>
public const string PinnedUrl =
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip";
private readonly string[] _searchDirs;
private readonly string _toolsDir;
private readonly Func<string, CancellationToken, Task<byte[]>> _downloader;
public FfmpegLocator(
string[]? searchDirs = null,
string? toolsDir = null,
Func<string, CancellationToken, Task<byte[]>>? downloader = null)
{
_searchDirs = searchDirs ?? ParsePath();
_toolsDir = toolsDir ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ytLlive", "tools");
_downloader = downloader ?? DefaultDownload;
}
public async Task<string> LocateAsync(CancellationToken cancellationToken = default)
{
foreach (var dir in _searchDirs)
{
if (string.IsNullOrEmpty(dir)) continue;
var candidate = Path.Combine(dir, FileName);
if (File.Exists(candidate)) return candidate;
}
var cached = Path.Combine(_toolsDir, FileName);
if (File.Exists(cached) && new FileInfo(cached).Length > 0) return cached;
try
{
var zip = await _downloader(PinnedUrl, cancellationToken).ConfigureAwait(false);
if (zip.Length == 0)
throw new IOException($"FFmpeg download from {PinnedUrl} returned an empty payload.");
Directory.CreateDirectory(_toolsDir);
ExtractBinaries(zip, _toolsDir);
return cached;
}
catch (Exception ex) when (ex is IOException or InvalidDataException or NotSupportedException)
{
AppLog.Write(ex, "FFmpeg locator: download/extract failed");
throw;
}
}
/// <summary>
/// Extract <c>ffmpeg.exe</c> and every <c>*.dll</c> into <c>toolsDir</c> via a
/// staging directory, so a failed extract never leaves a partially-populated
/// cache behind (the previous good cache stays until every move succeeds).
/// </summary>
private static void ExtractBinaries(byte[] zip, string toolsDir)
{
var staging = toolsDir + ".stage";
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
Directory.CreateDirectory(staging);
try
{
using var stream = new MemoryStream(zip, writable: false);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read);
var exe = archive.Entries.FirstOrDefault(
e => e.FullName.EndsWith("/" + FileName, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidDataException($"The pinned FFmpeg archive does not contain {FileName}.");
ExtractOne(exe, Path.Combine(staging, FileName));
foreach (var entry in archive.Entries)
{
if (entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
ExtractOne(entry, Path.Combine(staging, Path.GetFileName(entry.FullName)));
}
foreach (var file in Directory.GetFiles(staging))
File.Move(file, Path.Combine(toolsDir, Path.GetFileName(file)), overwrite: true);
}
finally
{
if (Directory.Exists(staging)) Directory.Delete(staging, recursive: true);
}
}
private static void ExtractOne(ZipArchiveEntry entry, string destination)
{
var temp = destination + ".tmp";
try
{
using (var source = entry.Open())
using (var target = File.Create(temp))
source.CopyTo(target);
File.Move(temp, destination);
}
finally
{
if (File.Exists(temp)) File.Delete(temp);
}
}
private static async Task<byte[]> DefaultDownload(string url, CancellationToken ct)
{
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
return await http.GetByteArrayAsync(url, ct).ConfigureAwait(false);
}
private static string[] ParsePath()
{
var raw = Environment.GetEnvironmentVariable("PATH") ?? "";
return raw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries);
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace ytLive.Services.Encoder;
/// <summary>
/// Resolves an absolute path to a usable <c>ffmpeg.exe</c>, downloading it on
/// first use if neither the user's PATH nor the local cache provides one — the
/// encoder's one external dependency is never shipped in the repo (TASK 4 ship
/// step 2; see TASKS.md). Seam so the encoder step and the tests can fake it.
/// </summary>
public interface IFfmpegLocator
{
/// <summary>
/// Returns the path to <c>ffmpeg.exe</c>: the first PATH candidate that
/// exists, else the cached copy, else a freshly downloaded one (ffmpeg.exe +
/// its libav DLLs extracted from the pinned BtbN LGPL-shared zip into the
/// tools directory).
/// </summary>
/// <exception cref="IOException">The download/extract produced no usable
/// binary (offline, expired pin, corrupt archive) — recoverable, logged.</exception>
Task<string> LocateAsync(CancellationToken cancellationToken = default);
}
+2
View File
@@ -31,6 +31,8 @@ External-facing logic: YouTube API, persistence. See
| `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H | | `Compositor/CompositorOptions.cs` | The active tier's output rect (source space over the 1920×1080 master, integer-aligned — `MainViewModel.OutputRectX` can be 656.5) + target W×H |
| `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) | | `Compositor/StretchMath.cs` | Pure pixel math: the WPF `UniformToFill` cover-crop, clamped bilinear sample/scale (unit-tested half of the compositor) |
| `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) | | `Compositor/StaticPixelCache.cs` | Asset bytes → cached BGRA8 `VideoFrame`, decoded once per content hash — the output path's raw-pixel source (the preview uses ImageCache's `BitmapImage`) |
| `Encoder/IFfmpegLocator.cs` | **TASK 4 ship step 2 seam**: resolves an absolute path to `ffmpeg.exe` (PATH first → cached `%APPDATA%\ytLlive\tools\ffmpeg.exe` → pinned BtbN LGPL-shared zip download). The encoder step and tests fake it |
| `Encoder/FfmpegLocator.cs` | The default locator: PATH probe wins, then the cache, then pull+extract (`ffmpeg.exe` **+ the `libav*.dll` family** via a staging dir, failures via `AppLog`). Pinned to BtbN `autobuild-2026-08-09-13-03` **lgpl-shared** (dynamic linking = LGPL compliance without static-relink §6 material; no GPL libx264; NVENC/QSV/AMF + libopenh264 + native AAC — see `ai.md` → Licensing). Search dirs / tools dir / downloader constructor-injected for hermetic tests. Not yet constructed by the app (the encoder step wires it) |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs) Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md). (no DI container yet). Models in [`Models/index.md`](../Models/index.md).
+61 -3
View File
@@ -169,8 +169,8 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
### Requirements: ### Requirements:
1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** — NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) — the format is NOT the differentiator, the license and per-GPU quality are. 1. **Encoding** — H.264 (hardware via NVENC/AMD, fallback x264) + AAC audio; **must comply**: keyframes ≤ 4s (gopSizeLong), closed GOP, AAC/MP3 @ 44.1/48kHz, mono/stereo only. **License posture (decided): GPL-free build** — NVENC (NVIDIA) / QSV (Intel) / AMF (AMD) + OpenH264 software fallback + built-in AAC; no libx264 (GPL contaminates a paid product). Output containers are identical either way (H.264+AAC in `.flv` for RTMP, `.mp4`/`.ts` for VOD) — the format is NOT the differentiator, the license and per-GPU quality are. **License guardrails (never violate — see `ai.md` → "Licensing — do not violate"):** only BtbN `lgpl`/`lgpl-shared` builds; never GPL (gyan.dev) or `nonfree` (fdk-aac); never static for distribution (LGPL §6 relink material); never link FFmpeg into the app; never drop `THIRD-PARTY-NOTICES.txt` from the app/About screen.
2. **RTMP push****FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push + reconnect. **Binary distribution (decided): check-then-pull** — probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (~30 MB, standard gyan.dev/BtB N — no custom minimal build) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it. Push goes to the cached reusable stream's ingestion URL 2. **RTMP push****FFmpeg subprocess (decided)**: app feeds raw frames via stdin, parses stderr for health; one battle-tested binary does encode + FLV mux + push + reconnect. **Binary distribution (decided): check-then-pull** — probe `where ffmpeg`/PATH at first go-live; if absent, download a **pinned** build (**BtbN LGPL win64 static** zip, ~75 MB — gyan.dev's builds are GPLv3 and ship libx264, which violates the license posture; BtbN's LGPL variant drops x264/x265 while keeping NVENC/QSV/AMF + libopenh264 + native AAC) to `%APPDATA%\ytLlive\tools\ffmpeg.exe` (extract just `ffmpeg.exe` from the zip) and cache it, offline-friendly. Behind an `IFfmpegLocator` seam so tests fake it (ship step 2, below). Push goes to the cached reusable stream's ingestion URL
3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**: 3. **Quality ladder** — the offered tiers, with **1080p60 @ 8 Mbps as the standard/default**:
- 720p30 @ 6 Mbps - 720p30 @ 6 Mbps
- 720p60 @ 6 Mbps - 720p60 @ 6 Mbps
@@ -192,8 +192,10 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
5. **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side) 5. **Health stats** — bitrate, FPS, dropped frames reported live in the bottom bar (encoder-side)
6. **One-click go live** — defaults that work out of the box 6. **One-click go live** — defaults that work out of the box
7. **Audio capture (feeds the meter — this task ships the wiring)** — WASAPI loopback (desktop/game at unity, zero UI — "it just is") + the picked mic (`MicSourceName` from the `MicPickerDialog`). The mic capture feeds `AudioLevel` so the realtime meter comes alive (today it reads 0 — the mixer feed is pending, see `ai.md` audio notes). AAC mono/stereo @ 48 kHz per the compliance rules. 7. **Audio capture (feeds the meter — this task ships the wiring)** — WASAPI loopback (desktop/game at unity, zero UI — "it just is") + the picked mic (`MicSourceName` from the `MicPickerDialog`). The mic capture feeds `AudioLevel` so the realtime meter comes alive (today it reads 0 — the mixer feed is pending, see `ai.md` audio notes). AAC mono/stereo @ 48 kHz per the compliance rules.
8. **Private-only go live until v1 (reputation guard, decided 2026-08-10)** — until the v1 release, go-live is **locked to private streams only** so a software error can never publish something public/unlisted that damages the creator's reputation. RTMP push itself has no privacy — privacy lives on the YouTube **live broadcast object**, which this app already controls via its OAuth API calls. So the lock is purely API-side: the Go Live flow always creates/updates the broadcast with `privacyStatus = "private"` and a guard **refuses** to set anything else (same spirit as the Live-only backdrop policy). The UI shows a clear "PRIVATE" badge next to the stream state so the creator always knows who can see them. Enforcement must be verifiable in the auth-service tests (fake the broadcast-insert/update call, assert `privacyStatus` is forced to private).
9. **v1 release gate: bundle the full license texts (decided 2026-08-10)**`THIRD-PARTY-NOTICES.txt` currently links the canonical license texts rather than embedding them. At the **v1 (GA) release**, the full texts of every license it names (LGPL v2.1+, BSD-2-Clause, MIT, Apache-2.0) MUST be bundled alongside it (shipped in the app output, e.g. a `licenses/` folder next to the notices file, still reachable from the About screen). This is a **release blocker for v1, not a task to queue early** — do it in the release pass. The repo should treat this like the private-only go-live gate: a checkbox that cannot silently lapse.
### Status: 🔶 In progress — **ship step 1 (the output compositor) SHIPPED** (2026-08-10); encoder/RTMP/audio follow it ### Status: 🔶 In progress — **ship step 1 (the output compositor) SHIPPED** (2026-08-10); **ship step 2 (the FFmpeg locator) SHIPPED** (2026-08-10); encoder/RTMP/audio follow it
The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame The pipeline chain the encoder needs doesn't exist yet: **scene compositing** (the master 1920×1080 frame
without the preview's editing chrome) → **audio capture** (WASAPI, feeds the meter) → **H.264+AAC encode** without the preview's editing chrome) → **audio capture** (WASAPI, feeds the meter) → **H.264+AAC encode**
@@ -266,6 +268,62 @@ made public (shared hex parse with the compositor — no duplicated color parsin
cleaned up — build **0 warnings**. Tests: the `SceneCompositorTests` integration test (full-scene master cleaned up — build **0 warnings**. Tests: the `SceneCompositorTests` integration test (full-scene master
pixels, vertical tier, flash) + 4 `StretchMath` units — **72 passing**. pixels, vertical tier, flash) + 4 `StretchMath` units — **72 passing**.
#### Ship step 2 — FFmpeg locator (the encoder's binary)
**Goal:** resolve a usable `ffmpeg.exe` on demand (the encoder's one external dependency), never shipping
a binary in the repo. Returns an absolute path; downloads only when neither PATH nor the local cache
provides one.
**Decisions (locked 2026-08-10):**
- **BtbN LGPL-shared win64 build** — not gyan.dev (gyan's "essentials" is GPLv3 and ships libx264, which
violates requirement 1's license posture) and **not the static lgpl build**: LGPLv2.1 §6 wants
relinkable object files for static linking, but the **shared** (dynamic-DLL) variant sidesteps that —
compliance is "license text + source offer + unmodified binaries" (see `THIRD-PARTY-NOTICES.txt` and
`ai.md` → Licensing). Drops libx264/libx265 while keeping NVENC/QSV/AMF, libopenh264 (the LGPL-legal
H.264 software fallback) and native AAC — exactly the requirement-1 encoder profile.
- **Pinned URL** — `https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-08-09-13-03/ffmpeg-master-latest-win64-lgpl-shared.zip`
(~75 MB zip — earlier "~30 MB" estimate corrected). A dated autobuild tag is immutable; BtbN retention
keeps the last 14 daily builds + each month-end build for 2 years, so a cold cache after retention
expiry 404s — a logged, recoverable failure (the seam throws; the encoder step surfaces it). Once
cached, the URL is never touched again. The pin is a single `const`, bumpable in one place — and must
always stay on the **shared** variant (never `gpl`, `nonfree`, or static; see ai.md Licensing).
- **Check-then-pull order** — (1) PATH probe (the user's own install wins), (2) cached
`%APPDATA%\ytLlive\tools\ffmpeg.exe`, (3) download + extract. Extract `ffmpeg.exe` **plus the
`libav*.dll` family** (the shared build's bin/ folder; Windows resolves the DLLs from the exe's own
directory) into a staging dir then move into place — a crash never leaves a corrupt or partial cache.
- **Seam** — `IFfmpegLocator.LocateAsync(CancellationToken)`: search dirs, tools dir, and the downloader
(`Func<string, CancellationToken, Task<byte[]>>`) are constructor-injected with production defaults, so
tests fake the network (feeding a real in-memory zip) and never touch disk outside a temp dir.
**New files (all in `Services/Encoder/`):**
- `IFfmpegLocator.cs` — the seam.
- `FfmpegLocator.cs` — the impl (PATH probe → cache → pull+extract exe + DLLs), failures logged via `AppLog`.
- `THIRD-PARTY-NOTICES.txt` (repo root) — the LGPL/BSD/MIT notices + source offer, copied to the build
output and surfaced via the top-bar **About** button (`MainWindow` code-behind, opens the file in the
OS viewer).
**Test plan:** the hermetic integration test drives the full decision ladder against a temp tools dir and
a fake downloader returning a real in-memory zip (`.../bin/ffmpeg.exe` entry): PATH hit wins without
downloading, cache hit skips the network, cold cache downloads → extracts → `ffmpeg.exe` lands in the
tools dir, and a second call serves the cache (downloader invoked exactly once). Focused unit tests:
**shared-build DLLs extract alongside the exe**, empty zip throws, missing entry throws, empty download
throws, downloader failure propagates, zero-byte cache is refreshed.
**Same-PR housekeeping:** requirement 2's stale binary facts corrected in this plan (~30 MB → ~75 MB zip;
"gyan.dev/BtB N" → BtbN LGPL-shared only, with the why); the "never do" licensing guardrails recorded in
`ai.md` so the reasoning survives.
**Out of scope (later ship steps):** the FFmpeg subprocess encoder (frames in via stdin, stderr health
parsing), RTMP push, WASAPI audio capture, the frame-pipeline wiring, health stats.
**Built (2026-08-10):** `IFfmpegLocator` + `FfmpegLocator` shipped in `Services/Encoder/`, pinned to the
**lgpl-shared** build `autobuild-2026-08-09-13-03` (extracts `ffmpeg.exe` + the `libav*.dll` family via a
staging dir). `THIRD-PARTY-NOTICES.txt` (repo root) ships to the build output and is surfaced by a new
top-bar **About** button; the "never do" licensing guardrails are recorded in `ai.md` — build **0 warnings**.
Tests: the hermetic `FfmpegLocatorTests` integration test (PATH → cache → download decision ladder with a
fake downloader serving a real in-memory zip) + edge/unit cases (shared-build DLL extraction, zero-byte
cache refresh, empty payload, missing zip entry, downloader failure) — **78 passing**.
--- ---
## TASK 5 — YouTube Live Stream Management ## TASK 5 — YouTube Live Stream Management
+78
View File
@@ -0,0 +1,78 @@
ytLlive — Third-Party Notices
================================
ytLlive is a paid, closed-source product. This file lists every third-party
component the product distributes or downloads, its license, and where to get
its source, so the LGPL/BSD/MIT obligations are met. Distribution obligations
are NOT optional: they attach because this product ships or automates the
download of these components.
If this file changes, update it here AND in the app's About screen (it opens
this file). See TASKS.md (TASK 4) and ai.md ("Licensing — do not violate") for
the guardrails — the "never do" list is there on purpose.
--------------------------------------------------------------------------------
1. FFmpeg (dynamic libraries + ffmpeg.exe, LGPL v2.1+)
Copyright (c) 2000-2026 the FFmpeg developers
License: GNU Lesser General Public License v2.1 or later
Home: https://ffmpeg.org/
Source: https://git.ffmpeg.org/ffmpeg.git
Used as: the streaming encoder/RTMP subprocess. This product distributes the
UNMODIFIED binaries; it never links FFmpeg into its own code (it is
launched as a separate process fed raw frames over a pipe).
Why LGPL (not GPL): a GPL build (e.g. gyan.dev, or BtbN's "gpl" variant)
would contaminate this proprietary product. Do NOT use one.
Why "shared" (not static): LGPL v2.1 §6 requires "relinkable" materials for
statically-linked libraries. The shared build links dynamically, so
the user can replace the DLLs — compliance is this notice plus the
source offer below, with no relink material required.
Compliance supplied by this product:
- The license text: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html
- The corresponding source / written offer to obtain it:
FFmpeg source https://ffmpeg.org/download.html
Exact binary https://github.com/BtbN/FFmpeg-Builds
Build tag: autobuild-2026-08-09-13-03 (variant lgpl-shared)
- The binaries are unmodified and the LGPL notices therein are intact.
2. BtbN FFmpeg-Builds (the exact binary this product downloads)
License: MIT (build scripts + repository) — the produced binaries are
covered by FFmpeg's LGPL (item 1).
Home: https://github.com/BtbN/FFmpeg-Builds
3. OpenH264 (libopenh264 — the H.264 software fallback encoder)
Copyright (c) 2010-2026 Cisco Systems, Inc. (and contributors)
License: BSD 2-Clause + Cisco's H.264 patent grant
Home: https://www.openh264.org/
Note: Cisco grants the patent license for its own H.264 implementation;
it ships inside the FFmpeg build above (LGPL obligations of item 1
apply to the library; the BSD terms apply to Cisco's code).
4. SQLite (bundled via SQLitePCLRaw's e_sqlite3 native bundle)
License: public domain (no rights reserved)
Home: https://www.sqlite.org/
5. Microsoft.Data.Sqlite (.NET data provider, statically linked into this app)
Copyright (c) .NET Foundation and contributors
License: MIT
Home: https://github.com/dotnet/efcore
6. SQLitePCLRaw (raw SQLite bindings + bundles)
Copyright (c) 2012-2026 Eric Sink and contributors
License: Apache-2.0
Home: https://github.com/ericstj/SQLitePCLRaw
7. .NET runtime / WPF / Windows SDK projections, incl.
System.Security.Cryptography.ProtectedData
Copyright (c) .NET Foundation and contributors
License: MIT
Home: https://github.com/dotnet/
--------------------------------------------------------------------------------
MISCELLANEOUS
- The full text of every license named above is available at the linked
canonical locations. The v1 (GA) release MUST additionally bundle the full
license texts alongside this file (a `licenses/` folder beside it, still
reachable from the About screen) — TASK 4 requirement 9, a release blocker.
- No warranty is expressed or implied for any third-party component.
+47 -3
View File
@@ -61,8 +61,10 @@ insert/idempotent/heal + HasBackdrop gate, IsLiveCapture, DisplaySource, INPC),
(the five canonical scenes, Live-only backdrop policy, EnforceBackdropPolicy), WebcamSafeguardTests (the five canonical scenes, Live-only backdrop policy, EnforceBackdropPolicy), WebcamSafeguardTests
(the per-scene size clamp incl. the Chat half-screen-area cap), SceneCompositorTests (the full-scene (the per-scene size clamp incl. the Chat half-screen-area cap), SceneCompositorTests (the full-scene
composite integration test: backdrop + round webcam + mirrored/bordered images + flash; the vertical composite integration test: backdrop + round webcam + mirrored/bordered images + flash; the vertical
tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear) tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear), FfmpegLocatorTests
72 passing. (the PATH → cache → download decision ladder with a fake downloader serving a real in-memory zip; shared
build DLL extraction) —
78 passing.
### Real-MainWindow tests MUST be hermetic (DB pollution bug) ### Real-MainWindow tests MUST be hermetic (DB pollution bug)
@@ -112,7 +114,7 @@ C# / WPF (.NET 8) following MVVM:
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs``%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`) - `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs``%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
- Scene/source/asset layout persists (SQLite, schema v6); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending) - Scene/source/asset layout persists (SQLite, schema v6); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream - `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
- Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is the next build** — full plan in `TASKS.md`; window capture (non-backdrop), the encoder + RTMP push, and audio capture follow it - Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED** — full plan in `TASKS.md`; the encoder subprocess + RTMP push, audio capture, and the frame-pipeline wiring follow (each its own PR)
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead - `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
### Screen backdrop capture (TASK 3 ship task #1) ### Screen backdrop capture (TASK 3 ship task #1)
@@ -312,6 +314,48 @@ stays XAML (editing view); the compositor is the output view.
resolver in the encoder step, not the compositor step. The master buffer (the compositor's return resolver in the encoder step, not the compositor step. The master buffer (the compositor's return
value) is the seam a future D3D11 compositor would honor identically. value) is the seam a future D3D11 compositor would honor identically.
### FFmpeg locator (TASK 4 ship step 2 — shipped 2026-08-10, plan in TASKS.md)
The encoder's one external dependency is `ffmpeg.exe`; it's never shipped in the repo. `IFfmpegLocator`
resolves an absolute path on demand: **PATH probe first** (the user's own install wins — their choice,
their responsibility), then the cache (`%APPDATA%\ytLlive\tools\ffmpeg.exe`), then a **pinned** BtbN
LGPL-**shared** win64 zip (~75 MB) from which `ffmpeg.exe` **and the `libav*.dll` family** are extracted
(staged temp-write + move so a crash never corrupts the cache; Windows resolves the DLLs from the exe's
own directory). BtbN LGPL-shared (not gyan.dev, not static): it drops GPL-only libx264/x265 while keeping
NVENC/QSV/AMF + libopenh264 + native AAC, and dynamic linking means LGPL compliance is "license text +
source offer" with no static-relink (§6) material — see the Licensing guardrails below. The pin is a
dated autobuild tag (immutable); BtbN retention keeps the last 14 daily + each month-end for 2 years, so
a cold cache can outlive the pin → the seam throws a clear, logged error (recoverable; the pin is one
const). Constructor-injected search dirs / tools dir / downloader (`Func<string, CancellationToken,
Task<byte[]>>`) keep it hermetic: tests fake the network with a real in-memory zip. Constructed in the
encoder step (not yet — this PR ships the seam + impl + tests only).
### Licensing — do not violate (GA = paid product; see `THIRD-PARTY-NOTICES.txt`)
This product is closed-source and paid. Every third-party component must stay inside the LGPL/BSD/MIT
guardrails below — written down so a future "quick fix" never reintroduces a GPL binary. **NEVER:**
- **Use a GPL FFmpeg build** — gyan.dev's builds are GPLv3 and ship libx264; BtbN's `gpl` variant is
GPL too. GPL in a distributed paid product is the #1 lawsuit risk. Only BtbN `lgpl` / `lgpl-shared`
builds are allowed.
- **Distribute the static lgpl build** — LGPLv2.1 §6 wants relinkable object files for static linking.
The **shared** (dynamic-DLL) build sidesteps that: compliance is "license text + source offer +
unmodified binaries". The pin is `lgpl-shared`; when the pin is refreshed, keep the shared variant.
- **Use BtbN's `nonfree` variant** — it adds fdk-aac (Fraunhofer code licensing). The native FFmpeg AAC
encoder is fine (no Fraunhofer code) but grants no AAC patent license — accepted low-risk posture for
RTMP→YouTube, since encoder vendors cover their implementations (Cisco OpenH264, NVIDIA NVENC, Intel
QSV, AMD AMF).
- **Link FFmpeg into the app** — it stays a separate subprocess fed frames over a pipe; that separation
keeps the app's own code out of LGPL reach.
- **Drop `THIRD-PARTY-NOTICES.txt`** from the shipped app or the About screen, or alter the FFmpeg
copyright/LGPL notices inside the downloaded binaries. Automating the download counts as distribution
— the obligations are not optional.
- **Pin to a moving target** — the `latest` BtbN release tag floats. Only immutable autobuild tags give
a reproducible source offer. Record the tag + variant beside the URL (TASKS.md) every time the pin moves.
- **Forget the v1 license-texts gate** — `THIRD-PARTY-NOTICES.txt` links the canonical license texts; at
**v1 (GA)** the full texts of every license it names MUST ship alongside it (TASK 4 requirement 9 is the
release blocker). Queued early is wrong; the release pass owns it.
## Design Principle ## Design Principle
> This software is so intuitive that even the most right-brained person can easily intuit and use it. > This software is so intuitive that even the most right-brained person can easily intuit and use it.
+169
View File
@@ -0,0 +1,169 @@
using System.IO;
using System.IO.Compression;
using Xunit;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// The FFmpeg locator (TASK 4 ship step 2): resolves ffmpeg.exe by probing PATH
/// first, then the cache in the tools directory, then pulling the pinned BtbN
/// LGPL-shared zip. The integration test drives the full ladder against a temp
/// tools dir and a fake downloader that returns a real in-memory zip; the units
/// pin down the failure and edge cases. No network, no real binary.
/// </summary>
public class FfmpegLocatorTests
{
private static byte[] MakeZip(
string exePath = "ffmpeg-master-latest-win64-lgpl-shared/bin/ffmpeg.exe",
string[]? dllPaths = null)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry(exePath);
using (var writer = new StreamWriter(entry.Open()))
writer.Write("dummy ffmpeg binary");
foreach (var dll in dllPaths ?? Array.Empty<string>())
{
var dllEntry = archive.CreateEntry(dll);
using var dllWriter = new StreamWriter(dllEntry.Open());
dllWriter.Write("dummy dll");
}
}
return ms.ToArray();
}
private static string TempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "ytllive-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
return dir;
}
private sealed class RecordingDownloader
{
public int Calls { get; private set; }
public byte[] Payload { get; set; } = MakeZip();
public Exception? Error { get; set; }
public Task<byte[]> DownloadAsync(string url, CancellationToken ct)
{
Calls++;
if (Error != null) throw Error;
return Task.FromResult(Payload);
}
}
[Fact]
public async Task Locate_Integration_FullDecisionLadder()
{
var toolsDir = TempDir();
try
{
// 1. PATH hit wins, downloader never invoked.
var pathDir = TempDir();
var pathExe = Path.Combine(pathDir, FfmpegLocator.FileName);
File.WriteAllText(pathExe, "user's ffmpeg");
var downloader = new RecordingDownloader();
var fromPath = new FfmpegLocator([pathDir], toolsDir, downloader.DownloadAsync);
Assert.Equal(pathExe, await fromPath.LocateAsync());
Assert.Equal(0, downloader.Calls);
// 2. Cache hit skips the network.
var cached = Path.Combine(toolsDir, FfmpegLocator.FileName);
Directory.CreateDirectory(toolsDir);
File.WriteAllText(cached, "cached ffmpeg");
var fromCache = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
Assert.Equal(cached, await fromCache.LocateAsync());
Assert.Equal(0, downloader.Calls);
// 3. Cold cache downloads exactly once, extracts ffmpeg.exe, and the
// second call serves the cache without re-downloading.
File.Delete(cached);
var coldTools = TempDir();
var cold = new FfmpegLocator([], coldTools, downloader.DownloadAsync);
var resolved = await cold.LocateAsync();
Assert.Equal(Path.Combine(coldTools, FfmpegLocator.FileName), resolved);
Assert.Equal(1, downloader.Calls);
Assert.True(new FileInfo(resolved).Length > 0);
Assert.Equal(resolved, await cold.LocateAsync());
Assert.Equal(1, downloader.Calls);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_ZeroByteCache_IsRefreshed()
{
var toolsDir = TempDir();
try
{
File.WriteAllText(Path.Combine(toolsDir, FfmpegLocator.FileName), "");
var downloader = new RecordingDownloader();
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
var resolved = await locator.LocateAsync();
Assert.Equal(1, downloader.Calls);
Assert.True(new FileInfo(resolved).Length > 0);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_SharedBuild_ExtractsDllsAlongsideExe()
{
var toolsDir = TempDir();
try
{
var downloader = new RecordingDownloader
{
Payload = MakeZip(dllPaths:
[
"ffmpeg-master-latest-win64-lgpl-shared/bin/avcodec-61.dll",
"ffmpeg-master-latest-win64-lgpl-shared/bin/avformat-61.dll",
])
};
var locator = new FfmpegLocator([], toolsDir, downloader.DownloadAsync);
var resolved = await locator.LocateAsync();
Assert.True(File.Exists(Path.Combine(toolsDir, "avcodec-61.dll")));
Assert.True(File.Exists(Path.Combine(toolsDir, "avformat-61.dll")));
Assert.Equal(1, downloader.Calls);
}
finally
{
Directory.Delete(toolsDir, recursive: true);
}
}
[Fact]
public async Task Locate_EmptyPayload_Throws()
{
var downloader = new RecordingDownloader { Payload = Array.Empty<byte>() };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
await Assert.ThrowsAsync<IOException>(() => locator.LocateAsync());
Assert.Equal(1, downloader.Calls);
}
[Fact]
public async Task Locate_ZipWithoutFfmpegEntry_Throws()
{
var downloader = new RecordingDownloader { Payload = MakeZip(exePath: "readme.txt") };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
await Assert.ThrowsAsync<InvalidDataException>(() => locator.LocateAsync());
}
[Fact]
public async Task Locate_DownloaderFailure_Propagates()
{
var downloader = new RecordingDownloader { Error = new HttpRequestException("offline") };
var locator = new FfmpegLocator([], TempDir(), downloader.DownloadAsync);
await Assert.ThrowsAsync<HttpRequestException>(() => locator.LocateAsync());
}
}
+4
View File
@@ -24,6 +24,10 @@
<Resource Include="Assets\llama-logo-icon.png"/> <Resource Include="Assets\llama-logo-icon.png"/>
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Include="THIRD-PARTY-NOTICES.txt" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup> <ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo"> <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>ytLive.Tests</_Parameter1> <_Parameter1>ytLive.Tests</_Parameter1>