f67a263ccdad7bbf5309d2f7e6794b7d817fd1a6
195
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
90cd039d98 |
Transitions: fade starts from the outgoing picture (was an instant cut)
The transition started with no from-frame, so BlendFrame returned the new scene at once. Start now requests a capture and the frame pump supplies its last output on the next tick. Test: TransitionFadeTests. Docs: ai.md, TASKS.md. Not pushed. |
||
|
|
a4dfca6b6c |
docs(memory): split the output path (ffmpeg, encoder, recording, audio) out of ai.md
Unit 3. Verbatim move of 'FFmpeg locator', 'Live encoder + RTMP push', 'Local recording' and 'Live audio capture' into docs/ai/output.md, grouped as encoder / recording / audio with a 'Related' footer. ai.md's stub keeps the decisions that must not be re-litigated or rediscovered: ffmpeg is bundled (never download/fetch; no-network test), args pure and no -re, probe order NVENC->QSV->AMF->OpenH264 and never libx264, REC/ON-AIR radio- exclusive by creator ruling, a modal's falsy result must never fall through into its side effect, full StopStream teardown on every failure, audio pipe closes BEFORE the video pump, and honest-by-measurement audio (true dBFS meters, SpeechLeveler + -1 dBFS limiter, live Func gains). - ai.md Services row and one 'FFmpeg locator section above' pointer repointed; MyMistakes.md dead-Pin entry repointed. - docs/ai/index.md gains the output.md row. ai.md 1,558 -> 1,289 lines. No source touched; no push. |
||
|
|
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. |
||
|
|
804b1d2f5f |
docs(memory): split the frame pipeline out of ai.md into docs/ai
ai.md is the mandatory read every session; at 2,702 lines / ~64k tokens it costs the whole subsystem history even when the task never touches it. This is a verbatim move, not a compression - nothing is deleted and each stub is written from the moved text. - docs/ai/frame-pipeline.md: 'Frame cadence', 'Per-element composite timing + scene dump', 'What the first instrumented take said', 'Render budget', 'Scene compositor' and 'Live frame pipeline', byte-identical, plus a short 'Related in ai.md' footer and two stale 'below' pointers corrected. - ai.md keeps two stubs carrying the invariants that must not be missed (overlay on every render path, VideoFrame.Epoch, StripPool threads, Release-only render numbers; arrival-stamped rawvideo, non-blocking submit, stop order, burned _outputIndex judge) and links the new file. - docs/ai/index.md + docs/index.md route the split; schema.md inventory lists the new tier. - 4 index-row references and 2 TASKS.md anchors repointed; ai.md's Services row too. Measured: ai.md 63.6k -> 47.1k tokens, 2,702 -> 1,975 lines. Progressive disclosure per schema.md convention 5. No source touched; no push. |
||
|
|
c768a8ee49 | fix(border-color): keep ComboBox popup clicks inside LeftPanel (TASK 63) | ||
|
|
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. |
||
|
|
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. |
||
|
|
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'. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
ccc1db52d3 |
Docs: the Gitea push path (HTTPS + credential helper), and why SSH was a dead end
The creator approved the push and it failed on the old SSH remote. The causes: the repo was RENAMED gramps/ytLlive -> gramps/LlamaCasty (the old path 301-redirects), and llgit:2214 refuses the publickey of this machine's key even though that key IS registered on the account (id 6, "Corsair VSC"). The account password in CREDENTIALS.md is also the Gitea account password, so the HTTPS push went through immediately: 931ab46..6be062c main -> main. origin is now https://llgit.llamachile.tube/gramps/LlamaCasty.git with the credential helper /home/gramps/bin/gitea-credential, which reads the password from the gitignored CREDENTIALS.md on demand - plain `git push` works, no plaintext secret on disk, no secret in any command line. Records the recipe and the lesson in MyMistakes.md (ask the REST API before doing transport archaeology: one authenticated curl answered everything that ten minutes of ssh -v could not) and the push mechanics in ai.md + HANDOFF.md. Docs only - no code touched, no build/test gate applies. |
||
|
|
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.
|
||
|
|
cdf82f688d |
Read the first instrumented take: the judder did not reproduce, no element is slow
The measurement unit existed to name which of the six scene elements eats ~140ms per composite. The creator's Release recording (ty-20261016-1841-559-0002.mp4, 174.5s, 1920x1080@60) names none of them — the whole composite costs ~1ms: composites 301 per 5s = 60/s, every slot, 0 dropped, 0 stalls after warm-up avg render 1.0-1.1ms (worst 2.5ms) = ~7% of the 16.67ms budget parts "#5 HD Pro Webcam C920 0.4ms, #5 ... raster 0.2ms, backdrop 0.2ms" - the other four elements never reach the 0.1ms print floor C4 cache 151R/150H: half the ticks were a whole-frame BlockCopy webcam 30.0/s at the resolver, longest gap 50.0ms (one 60Hz frame of sampling phase, not a drop) the FILE 29.6 distinct webcam images/s, 82% of images held exactly 2 frames, longest hold 267ms (the healthy 30fps-in-60fps signature) The two stalls are not composite cost: 124ms on the very first render (warm-up) and 53ms entering the "Chat" scene (a rebake). So the historic slow numbers are not a slow scene but different configurations: - Oct 9 19:48 (only bad take still on disk) was the VERTICAL tier: "FramePump started (1080x1920 @ 60 fps) scene='Live' comp=608x1080" - the 607x1080 centre crop upscaled to 2M px, 92 renders/5s, avg render 45.0ms, worst 278ms. - Oct 5 12:01 (141ms) logged no scene=/comp= at all - the suffix did not exist yet, so the map's "21:9 -> 1920x803" explanation stays a hypothesis, not a reading. - Every slow window shows "cache xR/0H" (the whole-frame cache never reusing) while this take shows 151R/150H, and one Oct 5 take latches mid-take (1.5ms -> 3.0ms -> 45.2ms in 15s, then stays). That latch is the lead for the next take; it is a hypothesis and the parts line is what will decide it. Corrections to the map (same commit, stale facts replaced not appended): - "It was never Debug-vs-Release" is WITHDRAWN: same 1920x1080 tier, Oct 9 = 10.4ms vs this Release take's 1.0ms, and Build <hash> carries no configuration - every pre-2026-10-16 render number is config-unknown. - The "same scene shape" 10:01/12:01 comparison is superseded: it was an eye comparison of takes whose geometry was never logged. New: scripts/webcam-cadence.py - the ground-truth probe for what the FILE holds, beside what the pump says it did. It took three wrong readings to get right (H.264 re-encoding defeats exact-pixel comparison; a round-clip crop's corners leak 60fps background; a fixed crop across a scene switch reads background), all recorded in MyMistakes.md as a recipe, including an ffmpeg-native cross-check (tblend=difference + signalstats.YAVG - the same metric, the technique already in this repo's A/V-sync recipe) which agreed: 52.2% duplicates / 28.6 distinct/s vs 50.7% / 29.6/s on different windows of the same take. Next: no fix is allowed yet - the creator makes a Release take in the failing geometry (vertical 1080p60 tier first), and that take's parts line names the stage. Docs in the same commit: ai.md (new "What the first instrumented take said" section, the two corrections, the parts-line denominators, the tool in the inventory), TASKS.md (reading outcome), HANDOFF.md (rewritten), MyMistakes.md (recipe). Gates: clean build 0 warnings, full suite 428/428, scope check passed. |
||
|
|
a3a4681aac |
Per-element composite timing + scene dump (measurement only)
The cadence work ended with a question the log could not answer: the compositor
manages 6.8 composites/s at ~141ms while the 10:03 take did 29-34ms on the "same
scene shape" (full-render split=0 elements=6 dynamic=5). Nothing in the stats line
said WHICH of the six elements cost what, and nothing recorded the scene itself, so
the two takes could only be compared by eye. This closes that gap and nothing else:
no fix is attempted until a real take names the culprit.
- Services/Compositor/CompositeTimings.cs (new): per-window collector.
Add(stage, ticks) accumulates; Describe(renders, minMs = 0.1) prints the averaged
breakdown slowest-first, dropping sub-0.1ms noise.
- SceneCompositor.Render / CompositeLayers / BakeStaticBase / BlitBakedElement /
BlitCachedLayer + SceneGraph.GetBakedBase: trailing optional CompositeTimings?
Every stage is timed (backdrop, base, flash, social, ticker, zoom, scale, overlay,
bake backdrop, bake overlays) and EVERY element gets its own entry keyed by
z-index + name ("#2 Webcam"), with the nested "raster" sub-stage recording the
paste-cache rasterize miss - the split between "re-samples every tick" and "slow
paste" without a second session. Bake-pass elements are prefixed "bake #..." so a
one-off rebake cannot masquerade as a per-composite cost. Callers that pass
nothing (thumbnails, existing tests) are unaffected and pay nothing.
- FramePump: owns the collector, threads it through RenderScene/RenderFull/
GetBakedBase/CompositeLayers, and logs "FramePump parts (N renders/5s): ..." once
per 5s window beside the stats line (averaged per render, so it compares directly
with "avg render"), then resets it. Also dumps the scene - "FramePump scene dump:"
header plus one line per element (z-index, name, kind, source type, visibility,
rect, opacity, clip) - at pump start and on every scene change.
- One Good Dog integration test (ytLive.Tests/CompositeTimingTests.cs): a real
composite of a full-canvas element plus a 64x64 one through the real collector
must rank the full-canvas element first, carry the raster sub-stage and the fixed
stages, and the dump must name both elements with z-index/type/rect and echo the
split point.
Derivative work (AGENTS.md): OBS's answer to "which source is slow" is the Source
Profiler - average execution time of each source's tick and render functions
(https://docs.obsproject.com/reference-libobs-util-source-profiler, util/profiler.h).
Same shape here: one timer per element per composite, reported as an average, plus
the stage split this case needs.
Docs in the same commit: ai.md (new subsection "Per-element composite timing + scene
dump", the closed gap corrected in "Frame cadence"), Services/index.md (new file row
+ FramePump/SceneCompositor rows), TASKS.md (unit marked shipped, next = creator
take), HANDOFF.md (rewritten: unpushed commit, next step, verify.sh whitelist hole).
Also recorded this session (from the creator): the launch command is
`dotnet run -c Release` - Release is the DEFAULT, Debug only when explicitly named,
and perf takes must read Release. ai.md -> Run updated.
Full suite 428 tests, 0 warnings; the only failures are the known audio env flakes
(both pass in isolation). Scope check passed.
|
||
|
|
8e1d6f44db | Scene strip tile artwork + on-air indicator (creator ruling 2026-10-10). Top-bar tiles now carry their own art: each canonical scene's tile is a per-scene CRT-styled image stored in the internal DB (Scene.ThumbnailAssetId - additive migration, seeded at heal from the creator's five Downloads JPGs via SceneThumbnailSeedPaths), and the preview pane keeps rendering the scene's ORIGINAL background - never the tiles. One shared on-air image (Gemini 4zajyl...jpg) replaces the staged scene's tile and follows the scene swap ('the on-air screen jumps around the top bar as the user swaps scenes'): stored as an Asset BLOB tracked by the OnAirImageAssetId Setting (EnsureOnAirImage, same contract as the alert clip) with MainViewModel.OnAirSeedPath override; RefreshSnapshotsAsync shows it for the staged scene, otherwise thumbnail-or-background art, and a staged Live with no on-air art still falls back to the green placeholder (minis never render live captures). Removed the 6px #22c55e rectangle in PreviewPane's backdrop ('green outline around the preview screen' that surfaced when a scene had no background) - the outer PreviewGlow red border is the real live signal. Doubled the scene-tile labels (FontSize 10 -> 20). Fixed a real bug this surfaced: Save()'s Asset pruner only kept Source/alert-clip rows, so the first save would have DELETED the seeded tile + on-air assets - the keep-set now includes Scene.ThumbnailAssetId and the OnAirImageAssetId Setting. New Settings/Load/Save migrations + SceneThumbnailTests (4 tests: seeded tile in strip, preview keeps original bg, Live-staged tile, no-seed fallback, on-air swap + persistence). 427 pass, 0 build warnings. (Unit: strip tiles + on-air) | ||
|
|
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) | ||
|
|
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) | ||
|
|
5685c5dc3a | TRAX: toggle refreshes the status light on play/pause, terse scrolling error on failure, fixed quiet volume (0.5), ITraxPlayer seam. Deadlocked test fixed via RunAsync (was GetAwaiter().GetResult on the UI thread). 416 pass / 1 known env flake. Refs: https://learn.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/async-programming-model-tap#dont-block-on-async-code (Unit: trax toggle) | ||
|
|
87f05ba2ce | Private broadcasts end like recordings: Enter keeps, Escape deletes (KeepBroadcastDialog). Privacy read from YouTube; delete = videos.delete (fallback liveBroadcasts.delete on 404). Refs: https://developers.google.com/youtube/v3/docs/videos/delete https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/delete (Unit: private broadcast close-out) | ||
|
|
b140024ebf | Audio: speech leveler (auto gain to -20 dBFS speech, -6..+20 dB, gated) between gate and compressor; honest mic meter (drop the +10 dB display boost and x1.2). Creator upload measured -28.5 LUFS; same audio through the new chain: -17.9 LUFS / -0.7 dBFS peak (Unit: audio loudness) | ||
|
|
8303b1cf7f | Brand flash OFF by default: MainViewModel.ShowBrandFlash master switch (default false) gates BrandFlashEnabled; feature kept, tests opt in; new test guards the default (Unit: brand flash off) | ||
|
|
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) | ||
|
|
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) | ||
|
|
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) | ||
|
|
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) | ||
|
|
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) | ||
|
|
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) | ||
|
|
c6569a7ccb | License: LlamaCasty is GPL-3.0-only with section 7 terms (attribution, rename modified versions, no trademark rights); icon + logo all rights reserved; LICENSE + ADDITIONAL-TERMS ship beside the exe; notices + ai.md rewritten off the paid/closed-source framing (Unit: GPL-3.0 licensing) | ||
|
|
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) | ||
|
|
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 |
||
|
|
948dd08214 |
Zoom: dims never shade the box — move the dim overlay above CanvasGrid (Unit: zoom shading fix)
Creator's final zoom correction: as the box is dragged, its capture region slides under the dim rectangles and the box picks up the 55% black "div shading" inside itself, because the VisualBrush samples the whole CanvasGrid and DimRects lived inside CanvasGrid. The captured area must stay clear regardless of box position. - PreviewPane.xaml: the DimRects ItemsControl is now a SIBLING of CanvasGrid (above the composed content, below the ZoomBox), never a descendant — the zoom source is shading-free by construction. - ZoomBoxInteractionTests: structural invariant — DimRectsLayer is a direct child of PreviewCanvas (not inside CanvasGrid) and renders below the ZoomBox. - Side effect (correctness, not a bug): quality dims now also sedate the global overlays outside the crop, matching "chrome outside the crop is dimmed". |
||
|
|
6b6284d05c |
Perf: dedicated strip-parallel composite (4 threads, BelowNormal) — UNIT 5
A composite splits into four horizontal row strips across four DEDICATED background threads; the calling thread takes a strip too (so four workers never mean five-way oversubscription), and anything under 64 rows runs inline. BlitContentRaw, BlitBakedElement and BlitOverlay now fan out. Output is byte-identical — strips already partitioned by row. The demotion deliberately does NOT come from Parallel.For. The pool is shared, so Thread.CurrentThread.Priority = BelowNormal inside it leaks into every unrelated work item that thread later runs — a full test run after trying it broke AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_ KillsTheLoopback. Established guidance agrees: use your own producer/consumer queue with your own Threads and set their priority yourself (https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority, https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool). Helpers/StripPool.cs is that queue. Four threads is the measured sweet spot: fewer leaves render over budget, more steals cycles from the game and the encoder and made render worse. The scaled blit's column LUT moves from a shared static buffer to ArrayPool<float>.Shared with a finally — a single static buffer is a data race the moment strips run in parallel. Gate: SceneCompositorTests.StripParallel_RepeatedRenders_AreByteIdentical — 8 renders of a scaled round webcam + mirrored image + master-sized flash over a short backdrop must be byte-for-byte identical, and must differ from a backdrop-only render so it cannot pass vacuously. It caught a loop-variable capture (new Thread(() => Worker(i)) saw i == Workers) that crashed the test host on the first run. |
||
|
|
9d04aea55b |
UNIT 0 reading: the camera was never the problem, so Unit 1 is the wrong lever
Take ty-20261005-1201-556-0001.mp4 (75.3s) plus its log settle it. The C920 delivers 30.0/s with ZERO gaps over 50ms and its own device stamps agree - camera, USB and driver are clean. The pump managed 34 composites per 5s at ~141ms each, and the file holds 6.8 distinct webcam images per second at 88.7% duplicates with no arrival hitch whatsoever. The judder is us holding one face for 140ms and padding the rest of a 60fps container, which is precisely what a lipreader experiences as "cutting out". So 720p is a SHARPNESS win, not a smoothness win: it triples the bytes the compositor stretches per render on a path already 4.4x over budget. The exclusive camera lock stays agreed and stays queued, but it moves after the compositor work or it makes the judder measurably worse. The number still unexplained: the 10:01 take hit 25.6 images/s and this one 6.8, with the same scene shape (full-render split=0 elements=6 dynamic=5) and the same 640x480 camera - composites went 29-34ms to 143-155ms between sessions and nothing logged says why. There is no per-element composite breakdown and no scene dump at pump start, so the next unit is measurement, not a fix. Recorded the pump-counter overcount (13/s vs 6.8/s: one 141ms composite lets ~4 camera frames arrive, and the counter stamps the first epoch the resolver sees, not the frame actually drawn) so it is not re-derived as a bug later. |
||
|
|
55ca1029a3 |
UNIT 0: make a dropped webcam frame measurable, not just inferable
The creator is DHH and lipreads their own recordings, so a webcam that judders is an accessibility defect, not a polish item. Take ty-20261005-1001-821-0001.mp4 holds 25.6 distinct webcam images/s against the camera's 30fps with 281 gaps of 50-100ms - roughly one hitch in five - in a file that muxes flawlessly. Nothing in the app could see that: rawvideo carries no per-frame timestamps (ffmpeg stamps by ARRIVAL at the declared 60fps), and the stats line divided every average by the number of frames SUBMITTED, which deadline padding always fills to the full target. So the pump reported "300/300 frames, avg render 12.4ms" while producing 60 distinct composites. Helpers/FrameCadence.cs is one pure counter fed from three points: MediaCaptureFrameSource logs the device's own SystemRelativeTime stamps against our arrival times (TryAcquireLatestFrame keeps only the LATEST frame, so falling behind is silent by design), and FramePump logs the distinct webcam Epoch values that reached a composite. Read together they name the culprit - device/USB, our reader, or our compositor - instead of guessing. Render/resolve/wait now divide by the real render count, and "frames per 5s from R renders" is the duplicate-on-lag ratio stated honestly. An out-of-order timestamp counts as a frame but produces no gap and does not become the next interval's reference, so one bad stamp cannot invent a multi-second hitch twice. Also corrects a stale ai.md line (the "webcam renders at ~18fps" figure came from a different session and counted every newly rendered layer) and re-records the background-removal ruling as permanently out. Records the agreed-but-unbuilt Units 1-2, the no-fallback-camera ruling, and the SetFormatAsync dead end found by probing the 19041 projection. Suite 402/402, 0 warnings. The one red, LayerReorderPersistenceTests' RealMouseDrag, fails identically on a stashed clean tree - it needs an interactive desktop session this shell lacks. |
||
|
|
509ef36aa0 |
Launch upper-left with 75px padding; stop restoring window position
Creator ruling: open in the upper-left of the screen with ~75px padding. The restore path was placing the window below the fold. RestoreWindowGeometry now ignores the saved position and calls the pure TopLeftPlacement(workArea, w, h, padding): work-area origin + LaunchPadding (75, in DIPs so it scales with DPI), clamped so the window is never pushed off-screen. A window that fits stays wholly visible; one too big to fit falls back to the work-area origin. Size and maximized state are still persisted -- only position was dropped. The size clamp is unchanged (saved values clamp to [MinWidth, work.Width], which is why a saved 800x500 returns as 1366x768 per the XAML minimums). The old clamp tolerated Left as low as work.Left - width + 120 and Top up to work.Bottom - 80, ranges wide enough to leave the window almost entirely off-screen while still looking like a successful clamp. A clamp that permits the window to be unreachable is worse than none: it reads as a guarantee it does not keep. BroadcastPullOutTests is renamed ..._Window_Launches_TopLeft_... and now asserts the constructor applies the derived placement for both an on-screen and a -99999 saved position. Placement math is covered by WindowPlacementTests as a pure function needing no HWND. Suite 395/395, 0 warnings. |
||
|
|
07a088a23f |
Bind broadcasts explicitly; the week-long RTMP dead end was never a bind
root cause (proven, not inferred): contentDetails.boundStreamId on liveBroadcasts.insert does NOT bind the broadcast. Our diagnostic reads lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING | streamStatus=active created is Google's "incomplete settings" state, so enableAutoStart has no bound stream to fire from; close-out then 403s invalidTransition because created -> complete is not a legal edge. A reusable stream accepts bytes for a broadcast that isn't watching it, so a perfectly healthy 99MB RTMP session is exactly what an unbound broadcast looks like -- every observable pointed away from the binding. Google's migration guide states the rule outright: a liveBroadcast must be bound to exactly one stream before it can start, and is not bound at creation. That was in the docs read during the original design and was not applied. Shipped: - BindBroadcastAsync -> POST liveBroadcasts/bind?part=id&id&streamId (no body), called from PrepareAndStartLiveAsync right after CreateBroadcast, before the pump. Failure is fatal to the session: an unbound broadcast can never go live, so a live-looking UI over one is a lie. - boundStreamId removed from the insert body, and the streamId parameter removed from CreateBroadcast. A parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible. - Replaced CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which asserted the disproven behaviour and therefore laundered a guess into a guarantee, with tests for the omitted field and the real bind endpoint. Error messages (creator ruling 2026-10-04): bind failures are not all "check the sign-in". Six documented reasons exist and only one is auth; BindFailureMessage gives each its own text, and the fallback passes the raw reason through verbatim so an unmapped reason stays greppable. liveBroadcastBindingNotAllowed matters most -- it means our ordering is wrong, and a generic re-auth message would hide a bug in our own code behind a plausible excuse. See MyMistakes.md. ai.md: also corrects the TASK 44 claim that liveChatId is only populated once the broadcast is live. The diagnostic shows it present while lifecycle is created. That claim was inferred from a sample's active-status guard, which is not a spec. Suite 395/395, 0 warnings. |
||
|
|
8fbc3701d2 |
fix: the broadcast diagnostic reported NO-BINDING from a parse that never ran
The run I asked for came back with three alarming lines: lifeCycle=? privacy=? recording=? bound=NONE boundMatches=NO-BINDING autoStart=unset autoStop=unset liveChatId=none | streamStatus=active Every single broadcast field was empty, and the stream half was fine. That combination is the tell: not a failed request, a wrong nesting level. liveBroadcasts.list returns a LIST wrapper, so the broadcast is at items[0]. I descended into items for the stream and read the response root for the broadcast, so every lookup missed, and BoundMatches — handed a null it could not distinguish from a real null — reported a confident NO-BINDING. The diagnostic invented the bug it was written to find. Three lines of fabricated alarm on the one run that was supposed to produce evidence. Two fixes, and the second is the one that matters: FirstItem() descends into items[0] for both halves, and returns default when there is genuinely no item. The line now prints boundMatches=unread when the broadcast could not be read at all, so an absent parse degrades to "I don't know" instead of a verdict. A diagnostic that guesses is worse than no diagnostic: this one would have sent me off to rebuild a binding that may well be correct. Test guards the regression: FirstItem_DescendsIntoTheListWrapper_AndReports- AbsenceHonestly, including the empty-items and missing-items cases. Suite 390/390, 0 warnings. Credit to the run for catching it. What it did establish, from the half that worked: streamStatus goes ready -> active about 30s into the push. So the ingest registers as active server-side, which combined with the 99MB muxed yesterday means the stream half is verified healthy end to end. The broadcast half needs one more run to say anything. |
||
|
|
0fb2f9c6c3 |
docs: Go Live IS private (verified), and v1 is where that guarantee ends
The creator's standing doubt: "I wasn't sure that the go live video would be
private." Answered by tracing every path that can touch privacy, not by
reassuring:
CreateBroadcast -> privacyStatus = "private" hardcoded
GoLiveViewModel -> Visibility => "Private" hardcoded
BeginTestStream -> StreamVisibility = "Private" hardcoded
UpdateBroadcast -> the only code that can change it — NO CALLER
UpdateBroadcast's sole caller in the repo is BroadcastPullOutTests.cs:120. It
is dead code. So there is no path today by which a broadcast this app creates
becomes non-private, and the "do not populate my channel with videos showing
the app at its worst" concern is fully met. Verified, not assumed.
Which makes the safety accidental rather than structural, and that is the part
worth writing down. LiveBroadcastFormViewModel still offers
VisibilityOptions {private, unlisted, public}, and UpdateBroadcast would honour
it. So the v1 visibility option is precisely the change that first wires that
method up — v1 is the moment private stops being guaranteed and becomes a
user-selectable default. Intended at v1, ruinous before, and "it's dead code,
let me call it" is not a valid reason to do it early.
Also recorded, having been absent from the map entirely: the TEST button is
hidden at v1. Its real job is being the guaranteed-private path (hence the
hardcoded Private at BeginTestStream:105), which the existing "dry-run"
comment on ShowTestButton does not capture — that framing is now known to be
incomplete, and must not be used to re-derive why the button persists.
Corrects a fact I got wrong one commit ago: the method is UpdateBroadcast, not
"UpdateBroadcastMetadataAsync", and I described it as an available retrofit
path when it is in fact unreachable in production.
Open, deliberately, for v1 rather than now: whether a dry run is still wanted
on its own merits once Test is hidden.
|
||
|
|
74da99c988 |
docs: the always-Private lock is a decision, and it unlocks at v1
Asked twice about a decision that has been settled since 2026-09-01, so this is a documentation failure rather than a question worth answering a third time. The note was already there and it was accurate. It still didn't work, which is the whole lesson: every sentence in it was descriptive. It said why the lock exists and where the unlock lives, and not one sentence told the reader what to DO when it looks like a defect. I read it, recognised it, and asked anyway — because recognition is not instruction. It also pointed at TASK 36 for the unlock while the creator's actual answer is v1, so it was confidently wrong on top of being non-directive, which is worse than saying nothing. Rewritten as a prohibition carrying its own trigger: verdict in imperative voice, then the observations that make it look broken (privacy=private in the diag line, the PRIVATE badge, recordFromStart/DVR on, no delete call in the codebase) so recognition fires the instruction instead of prompting a question, then the retired delete question with its verified answer. Also: it belongs to v1, not TASK 36, and UpdateBroadcastMetadataAsync:112 is a retrofit path, not the product change. MyMistakes.md gets the transferable part, because the fix is not "write it down" — it is "write decisions as prohibitions, not rationale, and treat a repeated correction as a broken note rather than an impatient user". TASKS.md gets it under 1.0 gates so it is a tracked deliverable, not prose. |
||
|
|
5ad0106502 |
diag: log what the broadcast is actually bound to while a stream runs
The logs answered the question and closed off a whole branch of investigation. The long run: broadcast inserted 14:17:04.577, ffmpeg to rtmp://a.rtmp.youtube.com/live2/5f6z-... at 14:17:05.686, and ffmpeg's own muxing summary at 14:18:56.473 reads video:101229KiB audio:1419KiB muxing overhead: 0.202949% ~99MB of real video plus audio over 112s, which is what 8000+128 kb/s is supposed to produce. No socket error, no 4xx from ingest, no reconnect. And the broadcast still never left `ready`; close-out 403'd invalidTransition again. The 8s run behaved the same way, so "YouTube needed longer" is disproven — 112s is longer. So the RTMP path is provably healthy. That is the finding worth keeping: it kills the entire "the push is broken" theory, which is where this had been heading for a week. The break is between ingest being accepted and the broadcast being told to start. Which puts the binding in frame, and the app was structurally blind to it. Google documents contentDetails.boundStreamId and says enableAutoStart fires "when you start streaming video on the bound live stream" — both valid on insert, neither restricted against reusable streams. But the migration guide also says a broadcast "is not bound to any streams at the time that it's created", and the failure that produces is invisible from the RTMP side precisely because a reusable stream will happily accept bytes for an unbound broadcast. Ingest succeeding proves nothing. Hence LogBroadcastDiagnosticsAsync, riding the existing health poll rather than adding a timer. One greppable line: lifecycle status, privacy, recording, boundStreamId *compared against the stream id we are actually pushing* (BoundMatches: yes / MISMATCH / NO-BINDING / unknown), boundStreamLastUpdate- TimeMs, autoStart/Stop, liveChatId presence, the stream's own streamStatus and activeLiveChatId, and the CDN settings we declared. Deliberately self-limiting: _broadcastSeenLive latches on live/liveStarting so the diagnostic shouts only while the broadcast is stuck and goes quiet once it is healthy. A permanent per-tick log line would be noise; this is the opposite. One test, per the Good Dog rule: BroadcastBindingTests pins the matrix and specifically pins `unknown` when there is no stream id to compare against, so a missing expectation can never be misreported as a match — that is the failure that would make this diagnostic lie. Suite 387/387, 0 warnings. |
||
|
|
cc2d1ef622 |
TRAX: a share that is gone must never freeze the startup window
Startup took 44s and 42 of them were one line. Measured from startup.log: OnStartup 08:42:59 -> MainWindow loaded 08:43:43, with the whole gap inside LoadLayout, ending at TRAX: failed to load '\\192.168.50.86\llamaVault\Assets\Music\...': The network path was not found. (0x80070035) LoadTrax called MusicPlayer.Load synchronously on the UI thread, and new MediaFoundationReader(path) does not fail fast on a dead SMB session — it burns the full ARP/TCP/SMB timeout and only then throws. The NAS had moved to .87 after the power outage, so an unreachable path became a 40-second frozen window on every launch. The rule, from the creator: a probe may time out; a bind should return immediately. Only one side of that is achievable here, and the reason matters — an unreachable UNC path has no fast-fail at all, so probing it on the calling thread is the worst possible probe. (A device open is the opposite: it normally fails fast on a sharing violation, which is why this bug is network-specific.) So: IsTrackReachableAsync runs the probe on a thread-pool thread under a Task.WhenAny ceiling (TraxProbeBudget, 2s) and treats offline/missing/slow as a normal state rather than an error. Only a probe that passed gets the real bind, and that bind deliberately stays on the UI thread alongside every other MusicPlayer mutation — moving the whole load off-thread would have introduced a race against PlayTrax/PauseTrax that does not exist today. The orphan probe thread stays parked until SMB gives up; it is not the thread anyone waits on. One test, per the Good Dog rule: TraxProbeTests. AHangingProbeIsAbandonedAtTheBudget_AndReportsOffline stands in a probe that never returns and asserts the caller is released and told "offline". Drop the ceiling and it fails on elapsed time — which is the frozen window returning. Suite 381/381, 0 warnings. |
||
|
|
ad10176e8b |
docs: llamavault is .87 now, and SMB must be reached by name
The 2026-10 power outage rotated the NAS off its DHCP lease: 192.168.50.86 is dead, 192.168.50.87 answers. The app had the literal IP baked into Music.TrackPath, so TRAX blocked ~40s on an unreachable network path during LoadLayout before giving up (0x80070035) — an unbounded SMB open on the startup path. Two facts banked so this is not re-derived: - Reach the share as \\llamavault\llamaVault\... The Windows hosts file pins that name to the current IP, so an IP change is now a hosts-file edit, not a database edit inside a running app. - The IP is DHCP. Ping before trusting any hardcoded path. DB repointed to the name (Music.TrackPath, one row); backup kept alongside it as ytLlive.db.bak-20261004-090001. Startup log truncated 16.7MB -> 118KB (AppLog.AppendAllText holds no long-lived handle, so truncating in place is safe). |
||
|
|
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. |
||
|
|
4fcf76289c |
TASK 48 item 1: bundle ffmpeg — the app no longer downloads an executable
Store policy 10.2.2 forbids dynamic code inclusion, and this was the only third-party binary the app ever downloaded and ran. It had already failed in production: the pin aged out of BtbN's 14-day retention and the download 404'd on the creator's first real recording attempt (2026-09-01). Bundling kills that failure class permanently and deletes the startup network dependency. - scripts/fetch-ffmpeg.sh populates a gitignored ./ffmpeg from a pinned month-end BtbN LGPL-shared artifact, SHA-256 verified before extraction, idempotent, FFMPEG_ZIP= override. 135 MB of DLLs in git would tax every commit on this 9p tree; the URL + hash are the audit trail LGPL §6 wants. - ytLive.csproj copies ffmpeg\** to output and publish (Always), and a GuardBundledFfmpeg target fails the BUILD if the folder is missing, so a fresh clone finds out before a take rather than during one. - FfmpegLocator loses the downloader, the zip extraction and the %APPDATA%\ytLlive\tools cache. Order is now bundle -> PATH, deliberately inverted: a PATH ffmpeg may be a different major version or a GPL build, and the bundle is what was pinned, license-vetted and tested. A missing bundle throws loudly; the old "found something on disk" path could satisfy a missing bundle with a stale copy, and silent drift is the worst failure mode for a dependency certification inspects. - ffplay.exe (18 MB) is not shipped; nothing calls it. - THIRD-PARTY-NOTICES.txt: "downloads" -> "bundled" throughout, and the stale build tag autobuild-2026-08-09-13-03 — the tag that 404'd — corrected to autobuild-2026-08-31-13-27 with the exact artifact name and archive hash. OpenH264 made precise: compiled INTO avcodec-63.dll, no separate binary. Checked whether other third-party binaries needed bundling: no. e_sqlite3.dll and WebView2Loader.dll already ship via NuGet into runtimes\win-x64\native\, OpenH264 is inside avcodec-63.dll, the rest are managed. Two gaps remain that bundling cannot fix, recorded in the map: the WebView2 Evergreen runtime is a machine prerequisite (TASK 48 item 2), and Velopack's updater downloads and executes, so its teardown is a compliance action. Tests: 16 locator facts, suite 379/379, 0 warnings. The integration test runs the bundled ffmpeg -version, which is the only assertion that proves the sibling libav*.dll resolved — presence checks pass with the DLLs missing and a shared build then fails to start. TheLocatorCarriesNoNetworkCapability keeps 10.2.2 from rotting back. Analysis: TASKS/research-store-certification.md §4. |
||
|
|
4873ef85ea |
docs: record the Google Cloud project owner + the OAuth verification blocker
Creator-confirmed: the Cloud project (number 198374416003, from the ClientId) is owned by llamachile.shop@gmail.com. Recorded because finding it cost a hunt, and because the trap was assuming it was the same account as the YouTube channel used in the app -- they are separate logins. Also records what the repo had nothing on at all: the OAuth verification path, the Testing-mode 100-user cap + 7-day refresh-token expiry that explains the repeated re-consent, the sensitive-vs-restricted fork (a paid annual CASA assessment if restricted), and two scope cleanups -- auth/youtube.force-ssl is redundant, and ChannelStatsService's playlistItems.list needs no OAuth scope at all. Docs only, no code touched. |
||
|
|
44344c9327 |
fix: Change Account signed out BEFORE the browser opened
Reported 2026-09-27: click avatar -> Change Account -> cancel in the Google chooser -> back to a signed-out app, then "cannot sign-in because the callback url was not released". Two independent defects: 1. ChangeAccountAsync ran `await SignOutYouTubeAsync(); await SignInAsync();`. SignOutYouTubeAsync -> TokenStore.Clear() -> deletes ytLlive.auth, so the working account's token was destroyed before the browser even opened. It is now just `await SignInAsync()`: success overwrites the token via the sessionChanged hook, cancel/failure keeps the current session. The explicit sign-out was never needed. 2. Abandoning the browser gives the app no signal, so the OAuth attempt held localhost:8765 for its full 5-minute timeout and a retry could not bind. A new AuthenticateAsync now cancels and awaits the abandoned attempt first. That takeover is serialised by a SemaphoreSlim: the first version cleared the in-flight marker before awaiting, so a caller arriving in that window saw a free port and raced to bind it -- reproducing the very failure it was written to prevent. My own test caught that; reasoning had "proved" it fine. Also adds an `openBrowser` ctor seam so tests never shell out to a real browser, and corrects the ai.md line that documented "Logout / Change Account -> SignOutYouTubeAsync" as intended behaviour. Test: YouTubeAuthServiceTests.RetryingAfterAnAbandonedAttempt_ReleasesTheCallbackPort Full suite 370/370, 0 warnings. Coverage gap noted in HANDOFF.md: the ordering fix has no automated test because _youtubeAuth is hardcoded in MainViewModel and ChangeAccountAsync is private. The reported cross-instance effect is NOT this cause and stays open -- no code path can sign out a second instance (per-instance TokenStore, local delete, no server-side revoke). |