Commit Graph
78 Commits
Author SHA1 Message Date
gramps 21db8a17b8 feat(chroma): detect a green or blue screen and build the key filter
TASK 64 units B and C — the two pure halves, no wiring yet.

- `Services/ChromaKeyDetector.cs`: samples the frame's outer ring (the screen is at the
  edges, the subject in the middle), reports Green/Blue/None plus the colour actually shot
  and a confidence. Confidence is coverage × hue tightness, which is what keeps a lawn or
  a football pitch from being offered as a keyable screen. Letterbox bars are excluded from
  the denominator rather than counted against the screen. Never decides anything: an offer
  is the creator's to accept.
- `Services/VideoFilterChain.cs`: options → `-vf` (scale → colorkey → despill → format=bgra)
  plus a `Signature` so two layers keyed identically can share one decoder. Numbers clamp and
  an unparseable colour falls back to the app default, so ffmpeg is never handed a filter it
  rejects at spawn.
- Keying is done by the bundled ffmpeg, not by us: verified against the shipped binary that
  `colorkey=…,format=bgra` emits straight-alpha BGRA (alpha 0 on keyed pixels), which is what
  the compositor already blends. No per-pixel loop lands in the 16.67 ms render budget.

Test: `ChromaKeyDetectorTests` — green, blue, letterboxed, lawn (must say None), mixed scene,
dark frame, plus the chain strings and signature sharing.

Docs: TASK 64 queued in TASKS.md with its slice table; the decisions.md chroma-key reversal
recorded as a reversal (it was buried for webcam background removal, not for supplied clips).
2026-11-05 08:05:18 -08:00
gramps 2670ac022b feat(media): play the clip's soundtrack and share one decode pipe
A video file was picture-only: the decode pipe passed `-an`, so a starting-soon
or BRB clip with music played silent everywhere. Adds sound to every clip and
collapses the two decoders into one.

- `Services/VideoClipPipe.cs`: the shared pipe behind `MediaVideoSource` (TASK 21)
  and `AlertClipDecoder` (TASK 47) — video (`rawvideo bgra`, scaled) + audio
  (`f32le -ac 2 -ar 48000`, 50 ms chunks, byte-carry across reads), both paced to
  real time, loop restarts both so picture and sound stay together. Alert
  behaviour is unchanged.
- `MediaVideoSourceManager` applies the layer's Level/Mute per session and raises
  `AudioAvailable`; `MainViewModel` feeds it to the new
  `AudioMixer.EnqueueDesktopMediaAudio`, which joins the DESKTOP/GAME ring so the
  desktop fader and the mic auto-ducker apply exactly as they do for game audio
  and TRAX (creator ruling 2026-11-05: audio joins the group).
- Per-layer `MediaMuted` + existing `MediaVolume` surfaced as Mute/Level in the
  properties panel; `MediaMuted` persisted (ALTER TABLE guard + SELECT/INSERT).
- Level/mute are per SESSION because one decoder is shared per file path — the
  last layer touched wins; one decoder per layer would play the file twice.

Test: `MediaSourceAudioTests` — the pipe emits 50 ms chunks at 48k stereo, the
manager scales by level and drops when muted, and a media layer's sound moves the
desktop meter. Alert/decode suites unchanged and green.

Docs: task-21 closed on item 10, the "silent soundtrack" facts corrected in
frame-pipeline/output/overlays, HANDOFF rewritten. verify.sh green (529 passed;
the two documented pre-existing failures only).
2026-11-05 07:54:06 -08:00
gramps 776f84fb88 docs(media): correct the audio claim — a video file's sound is silent, not loopback-carried 2026-11-05 07:22:15 -08:00
gramps 77d7208bfa feat(media): add a video file layer that decodes into the scene
TASK 21's picker slice: the decoder, manager, pacing and loop mechanism were
already shipped but nothing ever acquired a session, so a media Source rendered
nothing. Adds "Video File" to the (+) menu (video-filtered OpenFileDialog),
one decode session per distinct path with a HasSession guard, release when the
last scene reference goes, and a post-load reconcile. Loop is a manager-level
per-path answer ("any live reference loops") applied before StartAsync and
updated live via SetLooping. Properties panel shows the file with Change… and
Loop. Volume stays unwired: media audio rides the desktop loopback, TRAX-style.

Reference: OBS's media source (file → decode → composite into the scene) is the
established answer for this feature; the lifecycle shape mirrors this repo's
WebView2/ScreenCapture registration rather than inventing a new one.

Test: ytLive.Tests/MediaSourceAcquisitionTests.cs — add starts one decoder and
frames reach the layer, loop flip reaches the decoder, remove stops it.

Docs: task-21 closed, TASKS row + Day-4 row + stale 3·17 row corrected,
frame-pipeline acquisition note, HANDOFF rewritten. verify.sh green.
2026-11-05 07:17:43 -08:00
gramps 5c4acf141b feat(text): composite shared text rasters in output
Raster static text on UI-thread changes and reuse the same transparent BGRA frame in preview and all compositor paths. Cache text frame identity in the frame-pump signature and invalidate static bakes on edits. Follow OBS text-source conventions: https://obsproject.com/kb/text-sources
2026-11-04 15:01:47 -08:00
gramps 106896c408 docs: close Task 53 and advance queue 2026-11-04 13:49:39 -08:00
gramps 1993e7cc8b docs(task-53): accept vertical live-player check 2026-11-04 11:37:10 -08:00
gramps ede6ecac8e docs(task-53): retire platform safe-zone requirement 2026-11-04 11:35:29 -08:00
gramps bde2a4cf4b docs(task-53): accept six output samples and advance queue 2026-11-04 11:32:44 -08:00
gramps f921c0174b docs: reconcile Day 2 release policy and output tasks 2026-11-04 11:18:16 -08:00
gramps 20f059422b feat(alerts): add scene-local recent ticker
Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only.

Ref: https://obsproject.com/kb/obs-studio-overview
2026-11-02 10:47:48 -08:00
gramps ff5e8d94ae feat(entitlement): pivot to paid app ownership
Check Steam ownership of the paid app with BIsSubscribedApp; keep Debug fake ownership out of Release. Ref: https://partner.steamgames.com/doc/api/ISteamApps#BIsSubscribedApp
2026-11-02 09:18:03 -08:00
gramps 8f79b20a18 docs(distribution): Steam is the only channel — the zip, winget and the Store are dead
The site said "Coming soon to Steam" while the app docs still said direct download plus
winget primary with Steam parked. One of them had to move, and the creator's ruling is
Steam exclusive: a free app on Steam with the paid edition as a Steam DLC, and the
$10/month plan sold off-Steam by Polar.

Records the constraint that shapes the code, not just the prose:
- Steamworks requires in-app purchases to use the Steam Wallet (Microtransactions doc,
  quoted in commercial.md), so the monthly is *stated*, never *sold*, inside a Steam
  build; the lifetime is plain DLC bought on the store page and read with BIsDlcInstalled.
- Entitlement is `steamDlcOwned || polarKeyValid`, both resolving to one OutputProfile.
  steam_api64.dll ships beside the exe and is a functional dependency, not the rejected
  "IP protection" native DLL — without it DLC ownership cannot be read. Launched without
  Steam, the app falls back to the free profile rather than refusing to start.
- Signing is moot: Steam installs never meet SmartScreen. Artifact Signing stays on the
  shelf for the day a non-Steam download exists. Year-one fixed cost is ~$115.
- The exe rename must land before the first depot upload — Steamworks warns about
  renaming a live exe.

Distribution.md is rewritten to SteamPipe mechanics (publish output, depot, steamcmd,
DLC wiring, clean-account purchase/refund test). product.md, decisions.md (a new
"Steam exclusive" entry with every dead vector), TASKS.md (Steamworks entitlement row,
Steam packaging row), ai.md, index.md and the TASK 48 banner follow. MyMistakes.md gains
the Steam rules that cost real time today: installs skip SmartScreen, updates are free,
the Wallet rule, and the recurring-billing caveat.
2026-10-31 11:05:53 -07:00
gramps 7827d239e2 docs(commercial): one source of truth for the money — prices, entitlement, channels
The commercial plan was spread over five documents written in five different weeks,
and they contradicted each other: product.md sold the Store, decisions.md called the
Store dead then re-opened, Distribution.md (736 lines) still described Polar as a
download host, MARCOM.md quoted "no subscription" next to a paid tier, and README.md
promised "free forever". An outsider could not tell what ships.

docs/ai/commercial.md is now the only place money is decided: free = whole app at a
921,600 px / 30 fps output profile covering the recording as well as the stream; Pro =
$10/mo or $400 lifetime through Polar, key bound to the buyer's YouTube channel ID via
a claim page, 2 activations, 7–14 day lease with offline grace, fail closed, never
downgrade a running stream; one portable zip + winget primary, Steam and the Store
parked; Azure Trusted Signing (~$9.99/mo), no EV; Founding 200 promo; ~$135/yr costs;
anti-piracy posture and what is permanently rejected.

Everything else now points at it instead of restating it:
- product.md — money sections replaced with pointers; packaging rewritten to
  winget-first, Store parked; signing updated to Trusted Signing.
- decisions.md — graveyard with cause of death for free-forever, the Store ordering,
  the updater, and the 2026-10-30 reversal of the quality-ceiling rejection.
- Distribution.md — rewritten to release mechanics only (artifact, GitHub Releases,
  winget manifest, signing, checklist, takedown): 736 → 120 lines.
- TASKS.md — TASK 54 rewritten, TASK 48's route settled, TASK 10 superseded, five new
  rows (entitlement seam, output-profile gate, licence UI, system messages, packaging).
- README.md — "free forever" replaced with the real split.
- capture.md — obs-backgroundremoval is GPL-3.0 and so licence-INCOMPATIBLE since the
  proprietary EULA; the old "compatible" line was falsified by that change.
- TASKS/task-10, task-48, task-51, task-54 — superseded-banner at the top of each, so
  none of them reads as current policy.
- HANDOFF.md — rewritten: plan settled, next code unit is the entitlement seam.

MARCOM.md and MONETIZATION.md (gitignored) were corrected in place: policy pointer at
the top of each, the flash removed from every live claim, the Polar fee break-even
corrected to ~$900/mo, and the projections flagged as written against a dead model.
2026-10-31 08:32:25 -07:00
gramps 5a102a4a37 docs: decisions log, queue trim, HANDOFF rewrite — the map stops being a treadmill
TASKS.md 467 → 66 lines: one line per live item, detail stays in TASKS/
task-*.md. The old catalogue (63 rows of paragraph status prose plus the
historical narration) is preserved verbatim as TASKS/archive.md — a
2026-10-29 snapshot, not the live queue. The "out of product" list and every
retired decision moved to docs/ai/decisions.md with cause of death.

HANDOFF.md 440 → 40 lines, and it is now OVERWRITTEN, not appended: branch,
what's in flight, next action, live landmines, pending creator checks. History
belongs to git log; the previous handoff is reachable at ee3c592:HANDOFF.md.

docs/ai/decisions.md (new) collects what no longer governs: the paid tier and
its pricing, Polar, Store IAP, the Store route and why it died, the deleted
Velopack updater, signing economics, the retired monetization ideas, and the
permanent out-of-product list.

Facts that lived in the old ai.md "Current limitations" section were re-homed
rather than dropped with the router rewrite: OAuth/DPAPI/session behaviour →
docs/ai/youtube-api.md; paths, persistence and the Google project number →
docs/ai/environment.md.

verify.sh caps now cover all four always-read files (AGENTS 210, ai 100,
HANDOFF 40, TASKS 120).

Docs-only unit: scope-check run, no build.
2026-10-29 11:10:51 -07:00
gramps 096bf6debe docs(memory): split capture (backdrop, webcam, effects, vertical tier) out of ai.md
Unit 2 of slimming the mandatory read. Verbatim move again - the four capture
blocks leave ai.md unchanged in content, and one stub carries the law.

- docs/ai/capture.md: 'Screen backdrop capture', 'Webcam capture', 'Webcam
  background effects' and 'Vertical (9:16) tier camera placement', byte-
  identical, grouped under backdrop / webcam / vertical headings, with a
  'Related in ai.md' footer.
- ai.md stub keeps: the locked-Background model and NormalizeBackgrounds;
  ActiveBackgroundImage is fed by static art only (2026-08-23 regression);
  window:/picker: keys are session-scoped and a live pin beats auto-detect;
  the settled WinRT interop paths (TryGetDataUnsafe, from-DXGIDevice); the
  VideoFrame seam, own-catch subtype ladder and no fallback camera; one app-
  level webcam identity with live-lock attainability; CameraManager as the one
  effects processing point with per-session mask smoothing; geometry-only
  vertical tier and the model-level rect lock.
- docs/ai/index.md gains the capture.md row; Services/index.md (2) and
  TASKS/task-38-window-backdrop.md repointed.

ai.md 1,975 -> 1,558 lines. No source touched; no push.
2026-10-26 10:26:53 -07:00
gramps ef60f1f3b6 TASK 48: distribution & packaging is deferred until we are near v1
Creator ruling 2026-10-26: the remaining programming work is far from
release and a complete UX redesign is about to start, so packaging,
signing, installer and updater decisions would be chasing scope that is
about to move. The task is deferred, not cancelled.

Recorded what is frozen: TASK 48 items 6-8, the signing decision, the
updater choice (Velopack's deletion left no update path, and
StorePackagingTests currently vetoes a new one), and the route question
itself - ai.md says the Store route is dead (direct + Steam) while
TASKS.md/task-48/HANDOFF still read Store MSIX + Store IAP. That
contradiction is to be settled in ONE sweep at the revisit, not patched
piecemeal now.
2026-10-26 10:20:04 -07:00
gramps c768a8ee49 fix(border-color): keep ComboBox popup clicks inside LeftPanel (TASK 63) 2026-10-22 12:02:05 -07:00
gramps daac7689fd TASK 48 slice 2: the app packs as a Microsoft Store MSIX
The manifest the Store route needs, and a real package built from it.

Packaging/Package.appxmanifest is a full-trust packagedClassicApp: EntryPoint
Windows.FullTrustApplication, uap10:TrustLevel=mediumIL,
uap10:RuntimeBehavior=packagedClassicApp, the rescap runFullTrust capability, and
webcam + microphone DeviceCapabilities. That combination is what lets the packaged
app launch the bundled ffmpeg as a child process and write to the user's profile;
an appContainer package would virtualise the recordings folder instead. x64 only,
en-US only (policy 10.7).

Packaging/Assets/ carries the four tiles the packer requires, generated from the
app's own Assets/llamacasty-logo.jpg by scripts/make-store-logos.py - listing art
and app art cannot drift.

scripts/package-msix.sh publishes Release win-x64 self-contained into
artifacts/msix-stage (a RID-less publish would copy every RID's ONNX native),
stages the manifest as AppxManifest.xml + Assets, and runs the Windows SDK's
makeappx. MSIX_PFX/MSIX_PFX_PASSWORD sign a package for a local test install; the
Store signs submissions. artifacts/ is gitignored.

First real package: artifacts/LlamaCasty.msix, 159 MB, 509 files, 'Package
creation succeeded'.

Schema traps found by packing (makeappx reports one error at a time):
Square310x310Logo and Wide310x150Logo are NOT uap:VisualElements attributes - they
live on uap:DefaultTile.

Guard: StorePackagingTests.The_package_manifest_declares_full_trust_camera_and_
microphone - identity/architecture, the full-trust application element, the three
capabilities, no appContainer VALUE (not the file text: the manifest's own comment
says the word), en-US only, and every referenced logo present.

Full suite 438/438, 0 warnings. Cold-start smoke test after the Velopack removal:
MainWindow loaded, no exceptions.
2026-10-22 09:05:50 -07:00
gramps 20cfadca0c TASK 48 slice 3: the self-updater is deleted - the Store updates the app
The creator's ruling (2026-09-27) is Store MSIX + Store IAP, so Microsoft delivers
the updates and the in-app updater is gone rather than parked. This is a
COMPLIANCE action, not cleanup: the Velopack bootstrap downloaded and executed a
payload, which Store policy 10.2.2 does not permit.

Three edit sites, as planned: the PackageReference in ytLive.csproj, 'using
Velopack;' in App.xaml.cs, and the bootstrap block. The self-hosted DigitalOcean
update-URL droplet dies with it.

Guard: ytLive.Tests/StorePackagingTests.cs - the ONE test asserts the app project
references no updater package (Velopack/Squirrel/NetSparkle/AutoUpdater/WinSparkle)
and that no app source file uses the updater API. The check matches CODE (a using
directive, a type usage), not a bare word, so a file's prose may still name what it
deliberately does not do.

Smoke-tested after the removal: MainWindow loaded, 0 exceptions in startup.log.
Full suite 437/437, 0 warnings.
2026-10-22 06:57:34 -07:00
gramps b1d6034662 TASK 55 slice 1: the startup log is size-capped
The creator (2026-10-09): 'we need some sort of log-rotation so that we don't
inadvertently fill up a user's drive.' startup.log was uncapped - 23MB and growing
every few seconds, because FfmpegEncoder logs every non-progress ffmpeg stderr line.

The policy lives in a new Helpers/LogRotation.cs as a pure function over a path
(RollIfNeeded(logPath, maxBytes, retainedFiles)), so AppLog's 156 call sites keep
their surface and the rule is testable on its own. At 5MB the live file rolls to
startup.1.log and one rolled copy is kept - the FILE SET is bounded, not just the
live file, so a long stream cannot grow the log without limit. A failed rotation
degrades to the old behaviour instead of taking the app down.

Pattern reference (cited): Serilog's file sink pairs fileSizeLimitBytes (the cap)
with retainedFileCountLimit (how many rolled files to keep) -
https://github.com/serilog/serilog-sinks-file

Guard: ytLive.Tests/AppLogRotationTests.cs - the ONE test drives the real
AppLog.Write against a temp dir (two internal seams: PathOverride, MaxBytes) and
asserts the roll, the fresh live file, and that after a SECOND roll the directory
still holds exactly startup.log + startup.1.log.

Also fixed a stale catalog row: TASK 12 (master limiter) shipped 2026-08-15 and was
still marked 'Queued'.
2026-10-22 06:51:04 -07:00
gramps 826cc17e47 TASK 56: the secret scan is done - and the history is clean
The release gate before the repo goes public. Scanned 432 reachable commits (all
branches, 35 tags, the reflog, refs/stash) + the 63 dangling commits git fsck still
holds + every tracked file in the worktree, for Google OAuth client secrets, API
keys/tokens (Polar OAT, ghp_, AKIA, Slack, Stripe, OpenAI), private-key headers,
assigned credentials, and any once-tracked CREDENTIALS.md / OAuthCredentials.cs /
MARCOM.md. Report format is commit + path + rule only - no matched value ever
reaches the screen.

Result: 4 hits, ALL false positives - README.md:79 is the doc snippet's
'ClientSecret = your-client-secret' placeholder, TokenStoreTests.cs:22 is a 9-char
test dummy. Nothing to rotate, no history rewrite. The three secret files were
never tracked.

Also recorded: the repo was ALREADY public when the scan ran (anonymous GET -> 200,
API private=false), so the gate came late - but nothing leaked. The creator still
owns the keep-it-public decision.

Method + verdict in TASKS/task-56-secret-scan.md; the reusable recipe in
MyMistakes.md -> 2026-10-22.
2026-10-22 06:43:55 -07:00
gramps 987fddac24 TASK 63: the border-colour picker IS the app's own drop-down
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:

  "why does the drop down under the web cam properties not show the same style for
   the border color drop down list box?"

  "one of the first things we did was define branding. We established look-and-feel
   standards. And yet you keep ignoring these and I'm wondering why"

WHY IT WAS MISSED - the answer the creator is owed.
The TASK 58 sweep had already walked the whole app for un-themed controls and the
border picker STILL was not themed, because the sweep looked for control TYPES
(ComboBox / CheckBox / TextBox) carrying no theme style - and the border picker was
NONE of those. It was a "..." Button whose Click handler built a ContextMenu of
swatches in code. It was never in the sweep's scope, and PropertiesStyleTests.cs
(a type-based guard) could not see it either. A picker that LOOKS like a drop-down
is invisible to a type-based audit; the guard has to ask "is this the app's widget?".

WHAT CHANGED
- The picker is now the app's YtComboBox - the widget the Display and Font pickers
  wear - bound to the palette (SelectedElement.BorderColorOptions) with its selection
  bound by VALUE (SelectedItem -> BorderColorEntry), because WPF's SelectedValue
  matching is ordinal and the palette mixes #FFFFFF and #e94560.
- The closed box shows the swatch + the colour's NAME; the rows show swatch + hex +
  name (the rows the creator approved in TASK 60). A ComboBox cannot do that with one
  ItemTemplate - WPF copies it into the read-only SelectionBoxItemTemplate - so
  Helpers/ClosedBoxTemplateSelector.cs picks by the ContentPresenter's templated
  parent: the ComboBox for the closed box, the ComboBoxItem for a row. Verified
  against WPF with a throwaway probe, not assumed.
- The swatch is painted by the THEME (Helpers/HexToBrushConverter.cs, registered as
  HexToBrush) - the hand-painted SolidColorBrush(Color.FromRgb(...)) in the old
  click handler was the exact anti-pattern the theme forbids. The swatch also carries
  a 1px #2a3a5e border now, so black and dark swatches are visible on the dark menu
  (the follow-up TASK 60 left open).
- The hex field stays (60px, editable): a colour outside the palette can still be
  typed, and the drop-down then carries that hex as a NAMELESS entry ("no name" is the
  honest answer). The palette's name moved out of TASK 62's TextBlock into the
  drop-down's closed box.
- The hand-built menu, its ContextMenu/MenuItem/SolidColorBrush code and
  WebcamBorderColorPicker_Click are gone.

MODEL: SceneElement.BorderColorName -> BorderColorEntry (the palette entry for the
current hex, with its name; setting it writes the hex back) + BorderColorOptions (the
palette plus the custom entry), both raised when BorderColor changes.
BorderColorPalette gains EntryFor(hex) and OptionsFor(hex); NameOf delegates.

Guards: ytLive.Tests/BorderColorPickerTests.cs (the ONE test for this change - the
model, the panel, and the REAL widget: with the app's theme style and the panel's own
templates the closed box names the colour and the rows show the hex AND the name).
The TASK 60 guard (BorderColorPaletteTests) was updated because the mechanism it
asserted - a code-built menu - no longer exists; it now guards the new wiring (the
drop-down is bound to the palette and neither the panel nor the code-behind carries a
second list of colours).

Full suite: 435/435 passed, 0 warnings.
2026-10-21 23:09:11 -07:00
gramps ef52508b1e TASK 61 + 62: the geometry fields share one width, and the border field names its colour
The creator (2026-10-21), on Live -> Layers -> webcam -> Properties:

  "the Y and H boxes under webcam properties should be the same width as the X and W
   boxes and those widths should be fixed to accomodate the max number of possible
   digits providing the geometry values"

  "also the border color drop drown box content should also include the name of the
   color next to the hex value as they in the drop-down list box"

TASK 61 - one fixed width for the four geometry fields.
The row is a DockPanel, and WPF sizes an element to its CONTENT unless it is told
otherwise, while the LAST child of a DockPanel fills the remaining space. So X/W (not
last) were as wide as their text and Y/H (last) stretched across the row - two widths
for the same kind of field. Now all four carry Width="44" and the rows set
LastChildFill="False" (without it the last field ignores its own Width and fills anyway).
44px is the measured width of "-1920" in the app's font at 11px (the largest geometry
value - the master frame is 1920x1080 - plus a sign) plus the field's padding and
border. Docs: https://learn.microsoft.com/en-us/dotnet/api/system.windows.controls.dockpanel.lastchildfill
The guard MEASURES the widest value with WPF's FormattedText instead of asserting a
magic number, so a font change cannot silently make the field too narrow:
https://learn.microsoft.com/en-us/dotnet/api/system.windows.media.formattedtext

TASK 62 - the border field names its colour.
SceneElement.BorderColorName carries the palette's name for the current colour
("Crimson") and is raised when the colour changes; it is EMPTY for a custom hex the
creator typed, because "no name" is the honest answer. BorderColorPalette.NameOf
changed to match (it used to fall back to the hex itself; the TASK 60 test assertion
was updated with it). The hex field keeps a fixed width (60) so the name sits right
next to the hex instead of drifting to the row's far edge - the same trap as TASK 61.

Guards: ytLive.Tests/GeometryFieldWidthTests.cs, ytLive.Tests/BorderColorNameTests.cs
Full suite: 435/435 passed, 0 warnings.
2026-10-21 22:19:13 -07:00
gramps 6271bf34c9 TASK 60: the border-colour drop-down names every colour
The creator: "under Live->Layers->webcam->Properties->border color: in the drop
down box, the #hex values are good, but add a corresponding color name/label next
to the value."

Each row now reads "#e94560  Crimson" - the hex stays exactly as it was, the
colour's name goes next to it (dimmed #a0a0b0 so the hex reads first), and the
tooltip becomes "Crimson (#e94560)".

The 14 colours were a hard-coded string[] inside the click handler - a list of
bare strings cannot carry a name, and a list hidden in UI code cannot be asserted
on. They now live in Models/BorderColorPalette.cs: NamedColor(Hex, Name) +
BorderColorPalette.All (the single source of truth) + NameOf(hex), which answers
case-insensitively and falls back to the hex for a colour outside the palette.
The picker builds its menu from the catalogue and no longer knows any colour by
heart.

The names are chosen not to collide - the palette has two greens, two blues and
two warm colours: #00FF00 is Lime and #22c55e Emerald, #0000FF is Blue and
#3b82f6 Azure, #FF8800 is Orange and #f59e0b Amber.

Guard: ytLive.Tests/BorderColorPaletteTests.cs - every entry valid + named, no
hex or name repeated, the 14 legacy colours all still offered, NameOf answers,
and the old list is gone from the handler. 433/433 green, 0 warnings.
2026-10-21 20:03:19 -07:00
gramps 89f6aa3a67 TASK 59: the properties panel is a hierarchy, and the shape is a choice
The creator's ruling on the webcam's properties: a "Shape" title above the
Mirror/Rect row; the drag hint indented under that row "to show parentage";
the same for Geometry, Border Color, Border Thickness; Remove and Blur indented
under Background; Strength indented under Blur.

One rule covers it: a title owns its content and the content is indented one
level (10px) under it - two levels where a title is itself nested (Strength
under Blur under Background). The titles are SemiBold 11px #a0a0b0 like the
ones already there.

The shape also stops being a toggle button that shows the NEXT action ("Round"
when the camera was a rectangle) and becomes a CHOICE: two segment options,
Rect | Circle, with Rect selected by default - which is what the creator asked
for ("Rect should be selected as a default if none exists"). The old
ShapeButtonText is gone.

The mutual exclusion lives in the MODEL: IsRectShape/IsRoundShape on
SceneElement, each setter flipping the shape through ToggleClipShape() (so the
pre-round rect is still stored/restored) and always re-raising, so a refused
write - the vertical Chat layout is rect-only (TASK 57) - makes the UI snap
back instead of sticking. Deliberately NOT a WPF RadioButton group: a group
unchecks its siblings by setting IsChecked in code, which makes WPF drop the
binding (stackoverflow 7376373; the same trap measured in 50980687/57275046).

The Circle option stays hidden where CanToggleClipShape is false, so the circle
is still not offered in the vertical Chat layout.

Guard: ytLive.Tests/PropertiesHierarchyTests.cs - the model half (Rect by
default, pick circle, pick rect, the locked Chat layout refuses the circle) and
the panel half (the Shape title owns both options, the circle is the gated one,
the hint is indented and follows the row, every titled block's content is at
10px, Strength at 20px). 432/432 green, 0 warnings.
2026-10-21 19:58:28 -07:00
gramps 2273d20160 TASK 58: the properties panel wears the theme (no more light-system chrome)
The creator asked why the Stream Alerts "Display" drop-down was not in the same
style as the other drop-downs. It was never given the theme's YtComboBox style -
only a hand-painted Background/Foreground. Setting colours changes the paint, not
the template, so WPF still drew the light system ComboBox (grey border, system
arrow, white popup list) on top of the dark panel. The chat Font drop-down was a
copy of the same mistake, and every check box was the light system one.

The sweep of the whole properties panel (then the app):
  - ComboBox x2 (chat Font, alert Display) -> YtComboBox, hand-painted colours gone
  - TextBox x16 in the properties panel -> YtTextBoxCompact (new, BasedOn YtTextBox:
    11px / padding 4,2 - the panel is dense, the full 13px box would double it)
  - CheckBox x2 (chat) + x3 (preview broadcast) + x1 (socials dialog) -> YtCheckBox
    (new: a 14x14 rounded box + tick path, accent fill when checked)
  - the layer-rename field -> YtTextBoxCompact via BasedOn, still borderless
  - buttons, pills, sliders: already themed, untouched

The sweep also killed a typo'd foreground (#e0d0e0 for #e0e0e0) and the only
remaining hand-painted colour is the deliberate dimming of the two read-only path
fields.

Guard: ytLive.Tests/PropertiesStyleTests.cs (the ONE Good Dog test) parses the app
XAML and fails if an unstyled drop-down, check box or properties field comes back.
431/431 green, 0 warnings.
2026-10-21 19:46:25 -07:00
gramps 3a77662a95 vv webcam Unit 2: the Chat scene in the vertical tier - rect only, tile or one whole 607x540 area
The creator's locked table (2026-10-17) gives the Chat scene its own rules in
the 9:16 tier: RECT ONLY - the circle option is not offered - and a size that
is either the normal small tile or ENLARGED to fill one whole 607x540 area
(top or bottom). Unit 1 shipped the per-scene placement; this unit is the Chat
scene's own variant of it.

The rule lives on the MODEL, not on the button:
- Models/SceneElement.cs: ClipShape and ToggleClipShape() are now virtual.
- Models/WebcamSceneConfig.cs: VerticalShapeLocked (set per scene by the VM)
  OVERRIDES the ClipShape property, so no writer can slip a circle in - the
  toggle, the Shape button and the placement swap all go through it. The lock
  is cleared BEFORE the 16:9 layout comes back, so the desktop shape returns.
- ViewModels/MainViewModel.Webcam.cs: CanToggleClipShape (the circle is NOT
  offered in the vertical Chat layout - the Shape button disappears, it is not
  merely greyed out), IsChatSceneCamera, ResizeWebcamInVerticalTier.
- Models/VerticalCameraLayout.cs: FillsArea (the "enlarged" state, derived -
  no new column) and SizeFor - the Chat camera's TWO sizes, the tile or the
  whole area, whichever is nearer the requested width, so the resize handle
  has two detents instead of any size in between.
- An enlarged camera FILLS the area its centre is in (top or bottom), so
  dragging it up/down swaps the areas.
- The Chat camera is auto-tiled like the Live one when the vertical tier is
  picked - otherwise the vertical chat stream has no camera at all.
- Controls/PreviewPane.xaml.cs: the resize routes through the VM; the drag
  assigns, lets the model clamp into the band, then snaps (so the enlarged
  camera can be dragged from the top area to the bottom one).

Test: ytLive.Tests/VerticalChatCameraLayoutTests.cs - the ONE Good Dog test for
the unit: the vertical tier is picked -> the Chat camera comes into the band as
a small RECT tile -> the circle option is not offered and cannot be set even on
the model -> dragged up and enlarged it fills the TOP area exactly -> back to
16:9 the desktop layout is restored exactly and the circle option is offered
again. 430/430 tests pass, 0 warnings.

Research (the creator's table is the established practice, not our invention):
- Streamlabs' vertical guide: "splitting the screen in half horizontally,
  placing the camera feed in the top half and the main content on the bottom
  (or vice versa) may be the bet", and a just-chatting vertical wants the
  camera "to take up most of the screen"
  (streamlabs.com/content-hub/post/overlays-vertical-and-horizontal-streaming)
- Bloopbot's vertical overlay docs describe the same per-layer model we use at
  scene granularity: "move or resize a layer on Vertical and it stops being
  Auto-placed. It keeps the place you gave it, and the horizontal layout is not
  touched" (bloopbot.com/docs/vertical-overlays-twitch-dual-format) - our
  swap-at-tier-change, stored per scene.
- Landmine recorded: "about the bottom 560 px of the vertical video can be
  covered by Twitch's chat and player controls on phones" (Bloopbot) - the Chat
  camera's enlarged BOTTOM area may be half-hidden by the platform chrome;
  TASK 53's safe-zone work decides that.

Memory in the same commit: ai.md (the Chat rules, the shape lock, the tests),
TASKS.md + TASKS/task-57-vertical-camera-layout.md (TASK 57 complete), HANDOFF.md,
MyMistakes.md (3 recipes: lock the model property not the UI button; two detents
for a discrete size set; the edit tool cannot match an oldString with an em
dash), Models/ViewModels/Controls index rows.
2026-10-21 19:23:29 -07:00
gramps 6be062c45f vv webcam Unit 1: the vertical (9:16) tier keeps its OWN camera placement per scene
The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).

Geometry only - NO render-path change. The preview and the compositor both read
the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry:
- Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540
  areas, the six spots (top/bottom x left/centre/right), the 32px inset and the
  270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot,
  the corner the 16:9 default webcam uses.
- Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive
  columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; NULL = "this
  scene has no vertical placement yet"). The 16:9 layout stays in the old columns
  and is parked in the transient Landscape* fields while the vertical tier is
  live; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The
  X/Y/W/H overrides clamp every write into the band - outside it the camera is
  invisible in the vertical output.
- Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element
  constrains every writer (drag, resize handle, typed fields) in ONE choke point
  instead of three writers each re-implementing the rule.
- ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand,
  ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier /
  SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre),
  EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The
  Live scene gets its small tile automatically on the first vertical switch (the
  vertical stream would otherwise have no camera at all); the other scenes keep
  their 16:9 placement until the creator drags them into the band.
- Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under
  the vertical tier instead of free-floating over the master.
  Controls/LeftPanel.xaml: the webcam properties gained a Geometry section
  (X/Y/W/H) for the manual entry the creator asked for, bound to the LIVE
  placement, gated on the VM's IsWebcamSelected (NOT SelectedElement.IsWebcam -
  the TASK 51 test finds that panel by binding text and would match twice).
- The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the
  X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is
  live the save writes the parked values there and the live ones into Vertical*.

Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The
vertical tier brings the Live camera INTO the band as a small tile; dragging it to
the top-right lands it top-right inside the band; back to 16:9 the desktop layout
is restored exactly (1408/778/480x270); both placements survive a save + reload.
Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails
ALONE on a clean tree in this environment - pre-existing flake, proven by stashing
this work, not a regression.

Derivative work: OBS has no built-in per-aspect-ratio layout storage - the
maintainers' answer is "create a second scene and add the scene you want to send
as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect
guide says the same ("no built-in way to store separate layouts per aspect ratio",
so you rely on manual workarounds and plugins):
https://streamyard.com/blog/dual-aspect-ratio-streaming-tool
Our per-scene, per-tier stored placement, resolved on the tier change, is the
step past both.

Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md
(Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md
(the per-tier geometry-swap recipe, the binding-text panel trap, the store's
webcam-identity rule) and the Models/Services/ViewModels/Controls index rows.
2026-10-21 08:58:24 -07:00
gramps c2d64dcd6d TASK 51 blur 'heavy' fix: quadratic blur radius. BlurRadius(strength) = 1 + 5s^2, MaxBlurRadius 12 -> 6 downscaled px (~24 full px max). Default 0.5 is now radius 2 small px (was the old linear 6) - ~3x lighter perceived spread, matching the creator's real-camera finding. Range from the established tools: StreamFX defaults its Box blur Size to 5 and its author stops downscale-based blurs around 8px. One integration test pins the mapping (0 -> 1, 0.5 -> 2, 1 -> 6) and step-edge behaviour (light reaches 9px at default vs 26px at full); the existing slice-2 blur test passes unchanged. 419/419 pass. Refs: https://github.com/Vhonowslend/StreamFX-Public/wiki/Filter-Blur https://xaymar.com/articles/2020/04/26/optimizing-streamfx-in-your-obs-scenes/ (Unit: bg-blur heavy fix) 2026-10-10 07:08:01 -07:00
gramps 305706956e TASK 51 jitter fix: temporal mask smoothing. MaskSmoother blends each raw segmentation 0.75xnew + 0.25xprevious at the one CameraManager worker (state per CameraSession, not in the shared segmenter); null resets history, first frame passes through, input array never aliased, ping-pong buffers (no LOH alloc for a 147KB 256x144 mask). Creator's real-camera check: Remove was 'jittery' (per-frame independent segmentation). Weights from the OBS/Jitsi consensus. One integration test asserts the second frame keeps partial alpha (x=6 -> 58) where the raw mask cuts to 0. 416 pass / 2 known env flakes. Refs: https://deepwiki.com/royshil/obs-backgroundremoval/2-core-features https://github.com/jitsi/jitsi-meet/issues/17080 (Unit: bg-removal jitter fix) 2026-10-09 22:15:59 -07:00
gramps 352f117cb2 TASK 51 slice 5: webcam Background section in the Layers properties — Remove/Blur PillToggles (radio-exclusive) + Strength slider; strength changes never blank the webcam (new variant inherits the same-effect frame + bitmap). TASK 51 built (Unit: bg-effects UI) 2026-10-09 10:31:11 -07:00
gramps de22983b36 TASK 51 slice 4: camera-path hookup — CameraManager renders per-scene effect variants (one segmentation per raw frame on a latest-frame-drop worker, raw path untouched), VM declares/adopts variants per config; separable upsampling cuts Remove 46->3 ms, Blur 80->18 ms (Debug, 720p) (Unit: bg-effects hookup) 2026-10-09 10:27:38 -07:00
gramps 55210b9a61 TASK 51 slice 3: per-scene webcam BackgroundEffect + BackgroundStrength on WebcamSceneConfig, persisted via additive guarded columns; radio-pill bools RemoveBackground/BlurBackground (Unit: bg-effect config) 2026-10-09 10:19:48 -07:00
gramps 227ee8fca6 TASK 51 slice 2: Blur — background at 1/4 res, 3 separable box passes (~gaussian, radius by strength), mask-blended; person untouched; Remove gains strength = edge feather; mask computed once, rendered per effect (Unit: bg blur) 2026-10-09 10:18:00 -07:00
gramps fbeb63ba79 Docs: queue TASK 56 — pre-publication secret scan of the full git history (gate before the repo goes public) (Unit: task catalog) 2026-10-09 10:15:33 -07:00
gramps 4e42871fae Docs: README rewritten in the creator's voice (why it was built, generic comparison, free + open, real build-from-source steps incl. ffmpeg fetch + own OAuth client); source offer URL llgit.llamachile.tube/gramps/LlamaCasty in README + notices; TASK 51 keeps the strength slider (Unit: README + source offer) 2026-10-09 10:13:43 -07:00
gramps db2fdbf68d Docs: GPL-3.0-or-later (creator ruling); AI-generated scene backgrounds + alert video are GPL placeholders, not reserved; TASK 51 UI spec — Remove/Blur as radio-exclusive pill switches in the webcam's Layers properties (Unit: license follow-up + bg-effects UI spec) 2026-10-09 10:11:11 -07:00
gramps f529d47f42 Build: require 64-bit Windows — PlatformTarget x64 in app + tests (bundled ffmpeg is win64, ONNX Runtime has no win-x86, 32-bit address space can't hold 1080p buffers) (Unit: 64-bit requirement) 2026-10-09 10:01:38 -07:00
gramps 9f3c154d84 TASK 51 slice 1: background-effects engine — bundled MediaPipe Selfie Segmenter (ONNX) + Remove (Unit: bg-effects engine)
- Model: Google's official MediaPipe Selfie Segmenter (landscape, Apache-2.0 per model card),
  converted to ONNX by tools/selfie-seg/convert_tflite_to_onnx.py (custom op
  Convolution2DTransposeBias -> ConvTranspose) and verified against MediaPipe's own runtime
  (verify_against_mediapipe.py: max |diff| 5e-5, 100% binary agreement). Embedded resource, 430 KB.
- Services/BackgroundEffects: IPersonSegmenter seam, OnnxPersonSegmenter (onnxruntime 1.31.0 CPU,
  never throws), BackgroundEffectProcessor (None passthrough, Remove = alpha mask). Not wired yet.
- Tests: Remove integration test (fake segmenter) + bundled-model contract test vs MediaPipe
  reference mask. Full suite 407/408 — the one failure is the documented interactive-desktop flake
  RealMouseDrag_OnTheLayerList_PersistsTheReorder (passed in the run before; code path untouched).
- Docs: ai.md reversal + section + licensing guard, THIRD-PARTY-NOTICES 11-12, TASK 51, indexes,
  MyMistakes recipe, HANDOFF.

References: obs-backgroundremoval (approach only — ONNX Runtime on CPU, 2 threads; GPL-3.0, no code
used) https://github.com/royshil/obs-backgroundremoval ; MediaPipe image segmenter
https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter
2026-10-09 09:24:13 -07:00
gramps f8dd1079f3 Docs: queue TASK 55 — disk hygiene (log rotation, temp alert-clip cleanup, low-disk recording guard) (Unit: task catalog) 2026-10-09 09:06:36 -07:00
gramps 058bef4312 Docs: resettle TASK 54 on Fourthwall as provider of choice — kills the storefront-layer problem (Unit: marcom provider research) 2026-10-08 12:22:09 -07:00
gramps 29ef5d5f85 Docs: pivot TASK 54 storefront — Big Cartel is API-only, choose Ecwid free tier (Unit: marcom correction #2) 2026-10-08 11:30:26 -07:00
gramps d8febbd0d8 Docs: correct TASK 54 merch plan — Printful Quick Stores is US-only; storefront layer = Big Cartel free tier (Unit: marcom correction) 2026-10-08 10:32:55 -07:00
gramps 9fb628b473 Docs: record Shorts/TikTok output-format verification (TASK 53) + free-forever/merch round (TASK 54); mark TASK 10 superseded (Unit: task catalog) 2026-10-08 10:20:01 -07:00
gramps fe80e8c994 Docs: record the zoom box as TASK 52 and fix the task-numbering drift (Unit: zoom docs)
The zoom feature shipped without a task record while its index rows carried the
"TASK 51" label — which belongs to the webcam background effects (Remove + Blur).
For an honest catalogue and handoff:

- TASKS/task-52-zoom-box.md: feature provenance (creator-requested), the three
  2026-10-08 corrections (loupe -> anchored capture over the full composite drawn
  last -> never show the dim shading), shapes, implementation map, tests, DONE.
- TASKS.md: row 52 added (shipped); row 51 reworded — unblocked now that the
  zoom build shipped.
- TASK 51 -> TASK 52 in Models/index.md, ViewModels/index.md, and the two code
  comments that pointed at the zoom as "TASK 51".
- HANDOFF.md: pre-push readiness notes (5 commits ahead, push-safe).
2026-10-08 08:52:36 -07:00
gramps 4b3d82ff0f Zoom: compose the box as a real 2x layer above the background (Unit: zoom layer + magnification proof)
Right-click now creates a transient ZoomSceneElement at z-index 1 whose pixels are a
genuine 2x magnified sample of the backdrop (live capture under art), composited into
stream/recording like any layer while the output crop stays the tier. All shapes are
centered on the click and clamped to frame; healed on load/scene-create, never persisted,
hidden from the layers list + element template (IsZoomLayer). BuildCompositorOptions is
now internal = always tier crop (test seam). ZoomFrameBuilder is a pure static sampler
(box/2 window centered on the box center, single-slot cache) proven by a banded-backdrop
test; LayerReorderPersistenceTests drag resolves rows by item since the collapsed zoom
row shifts container indices. Also books TASK 51 (webcam background Remove + Blur) and
syncs memory docs (HANDOFF, MyMistakes, indexes). Full suite: 405 passed, 0 warnings.
2026-10-07 23:39:54 -07:00
gramps f3fed0a2a8 docs: purge the dead $29/$49 + Polar model from every tracked file, add the Y1 forecast
The pricing ruling landed twice (2026-09-21 one-time, then 2026-09-27 Store IAP
$10/mo + $400 lifetime) and the map never caught up. Six tracked files still told
a future session that billing was Polar and the price was $29/$49. A stale memory
map is worse than a stale comment: it is the file every session trusts, and this
one was actively steering work toward a deleted provider.

MONETIZATION.md (gitignored, so not in this commit) was rebuilt first:
- "Revenue projections" now carries a modelled Year 1 forecast —
  pessimistic $4.0K / base $15.4K / optimistic $53.3K net, ~$22K weighted.
  Funnel is installs -> 35-45% activate -> 1.5-3% of active users pay.
- Three findings recorded: Year 1 is ~97% lifetime transactions (subs net $392
  of $15.4K, so MRR is not a Year-1 planning input); conversion dominates price
  (~1pp of conversion = ~$7K net, and ADA 6(h) locks $10 anyway); subscriber
  churn moves Year 1 by under $200 and is a Year-2 problem.
- Break-even restated for $400: $25K net = 74 lifetime buyers, not 862.
  The price cut the volume requirement ~10x and raised the skill requirement
  to match.
- Market sizing willingness-to-pay row rewritten. The 100K-400K TAM was
  price-bound to $29-49 and is wrong at $400; revised ~20K-60K, flagged as an
  estimate rather than a sourced number.
- Flagged a real contradiction: $500K ARR needs ~4,900 concurrent subscribers,
  so the Exit section's ARR multiples do not describe what Years 1-3 produce.
  The defensible asset there is lifetime cash flow, not ARR.

Tracked files corrected to match:
- ai.md: paid-delta, pricing and billing bullets rewritten; Polar product id,
  org id, LCYT- key prefix and LLAMAFOUNDER/LLAMA50 marked as deleted;
  15% share + net-after-tax recorded with the ADA 6(b)(i) citation.
- TASKS.md: catalog row retitled as a teardown; the "still to verify — the
  price is not decided" note closed (it contradicted the ruling 20 lines
  below it); the OverlayHost "Polar unlocks alerts" open question marked
  resolved; Polar file-hosting deliverable removed from the release build.
- task-10: reframed as a teardown. Steps 4-5 marked as surviving, 1-2-3-6-7 as
  deleting, 8 as a compliance action (Velopack downloads AND executes, which
  Store policy 10.2.2 does not permit).
- task-36 item 5 (license-expiry reminders) struck — no key, no expiry, no
  lapse under Store IAP, so there is nothing to remind anyone about.
- Distribution.md 4.2 / 4.4 / 4.5 marked obsolete; 4.5's "already implemented"
  corrected, since PolarLicenseService is being deleted rather than shipped.
- README: "one-time license" -> "paid tier".

Historical Polar sections are retained deliberately — the reasoning is reusable
(merchant-of-record, fee comparison), only the conclusions are dead. Each is now
labelled so it cannot be mistaken for the plan.

No code touched.
2026-10-02 20:56:37 -07:00
gramps 2569c09617 task 50: correct mechanism — creator's channel proves stream never lands 2026-09-28 12:11:10 -07:00
gramps 3637950df5 task 50: one-button publish the local recording (creator ruling 2026-09-28) 2026-09-28 12:04:55 -07:00