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:
@@ -105,6 +105,8 @@
|
||||
|
||||
<!-- Single three-state action button -->
|
||||
<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"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center"
|
||||
ToolTip="{Binding AccountDisplayName}"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Collections;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
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)
|
||||
{
|
||||
if (sender is Button { ContextMenu: { } menu } button)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/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`) |
|
||||
| `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)
|
||||
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
|
||||
|
||||
@@ -169,8 +169,8 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
|
||||
|
||||
### 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.
|
||||
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
|
||||
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 (**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**:
|
||||
- 720p30 @ 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)
|
||||
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.
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -61,8 +61,10 @@ insert/idempotent/heal + HasBackdrop gate, IsLiveCapture, DisplaySource, INPC),
|
||||
(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
|
||||
composite integration test: backdrop + round webcam + mirrored/bordered images + flash; the vertical
|
||||
tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear) —
|
||||
72 passing.
|
||||
tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear), FfmpegLocatorTests
|
||||
(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)
|
||||
|
||||
@@ -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`)
|
||||
- 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
|
||||
- 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
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@
|
||||
<Resource Include="Assets\llama-logo-icon.png"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="THIRD-PARTY-NOTICES.txt" CopyToOutputDirectory="PreserveNewest"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>ytLive.Tests</_Parameter1>
|
||||
|
||||
Reference in New Issue
Block a user