Commit Graph
61 Commits
Author SHA1 Message Date
gramps 80ccdc9cb8 docs(readme): new hero screenshot (1400×862, 289 KB)
Creator-supplied capture of the main window: five scenes across the top, the Starting scene with its
three callouts in the preview, the layer panel, and the audio dock.

Re-encoded with the bundled ffmpeg instead of the WPF resize tool (scale 1400 + q4) — one command, the
same tool the rest of the assets use — and that shortcut is recorded in MyMistakes next to the original
recipe so it is not re-derived a third time.
2026-11-09 14:32:02 -08:00
gramps ec6a5ca466 docs(queue): six veggies, then the new work; scheduling and publishing retired
Creator ruling 2026-11-09: finish the queue before the new features, gold pass always last.

**Retired (platform's surface, not ours):**
- 34 scheduled streams — YouTube owns scheduling *and* the reminder that makes it worth anything.
- 50 publish recording — Studio owns upload (resumable, quota-aware, explains rejections), and an
  in-app uploader would widen the OAuth consent to whole-channel video management for one convenience.
  The app adds "Show in folder" instead.

**New tasks:** 65 replay clips (the creator-side buffer YouTube does not give you), 66 timed ticker
messages, 67 per-event alert videos, 68 edit-while-live + first-run On-Air.

**TASK 65 spec** is the interesting one: 15 s is a clip (10/15/20, default 15); the ring holds encrypted
*encoded* fragments in RAM — raw 1080p frames would be ~7.4 GB for 15 s — so there is no steady disk
write and no crash debris; F10 saves; the replay layer plays once then hides in Live and loops the last
1–3 elsewhere; silent by default; three kept; the layer is excluded from its own clip (hall of mirrors).

**TASK 14 gains Branch 3:** mic input gain ahead of the gate (table boom mics fall under it), webcam
blank hotkey, opacity/geometry parity with a collapsed Geometry disclosure, Show-in-folder.

**Decisions recorded:** the replay-buffer reversal (it was retired in 2022-era reasoning as "different
product"; the case for it won), the platform-surface rule, and voice triggering deferred — always-
listening in a streaming app hears the game, so the content is its own false-positive source.

Queue refactored to 56 lines by archiving completed rows and the superseded surge plan; HANDOFF at 40.
2026-11-09 14:27:07 -08:00
gramps b39439abdc docs: the vertical take is clean — 29.8 distinct webcam images/s
Creator's Release take `ty-20261109-1142-036-0005.mp4` (1080×1920 @ 30, 300 s) measured by cropping the
webcam tile and diffing frames: 29.8 distinct images/s overall, median 30 s window 30.0/s, worst 24/s,
one 4-frame repeat run in 9026 frames. The app's own lines agree (`webcam 28.4–30.0/s` against camera
`arrivals 29.9–30.1/s`).

- The 25.6/s failure recorded on the 2026-10-05 16:9 take does **not** reproduce in the vertical tier,
  so the vertical take no longer blocks the cadence units.
- Recorded the measurement trap in `frame-pipeline.md`: a threshold of 1.0 on a downscaled tile
  reports long "0.0/s" stretches during low motion — duplicates sit below 0.02, a still face at
  0.3–0.7. Read the histogram before believing a count.
- Vertical-tier camera check reported 1–6 pass; both creator-hands items in TASKS.md are closed.

(The mic being off for the first 2:42 does not affect video cadence.)
2026-11-09 11:53:58 -08:00
gramps 92d875c343 tools(stats): mine sessions from the logs, split by sink
`scripts/session-stats.py` turns every `startup*.log` into one row per FramePump session: tier,
scene, measured fps, drops, stalls, average/worst render, capture rate, and the build — with the
sink marked, because that is what makes the numbers quotable:

- **RECORD** — machine only, no socket. The cleanest minimum-config evidence.
- **TEST** — machine + socket, no audience; recognised by `Test session dock open` /
  `Test chat simulation`, which the creator pointed out are recorded too.
- **LIVE** — network and audience included.
- **UNKNOWN** — no marker; not evidence.

Verdicts use the average render against the frame budget, not the worst: the worst is dominated by
the 100 ms first-frame bake (a session that "holds" can still show worst 35 ms). `holds` = no drops,
no stalls, average under half the budget.

First run over the existing logs already separates the two halves of TASK 33: static Starting scenes
render at ~1.5 ms, the same pump with display capture running at 35–46 ms — the capture path, not the
overlays, is where a budget streamer loses the frame budget.
2026-11-09 09:04:04 -08:00
gramps ed32151136 docs(33): cause-classified step-down, and a toast that owns the mistake
Design settled with the creator. Two findings drove it:

- **Resolution is not a bandwidth lever.** 1080p30 and 720p60 are both 6 Mbps in the shipped table,
  so "step down to 720p" relieves nothing against congestion; and 60→30 at the same bitrate relieves
  nothing either. Congestion therefore steps **bitrate only** (8.0 → 6.4 → 5.1 → 4.1, ~20%), which is
  what OBS Dynamic Bitrate does — cited, adopted, with its climb-back discarded for v1.
- **The other half has no OBS equivalent**: machine starvation (the game and the app on one PC) steps
  fps, then resolution, and sheds LlamaCasty's own capture cost *first*. That is the app taking
  responsibility for a stream it is helping to starve.

Budgets as ruled: 3 steps for a 1080 start, 1 for 720 and vertical, hard floors at 720p30 /
Vertical 1080p30, hold-and-warn at the floor, never disconnect. Trigger ≥5% drops over 15 s twice,
30 s cooldown, ambiguous evidence does nothing.

New rule in `ui-surface.md`: a toast names the cause, the culprit and the remedy. Mechanism strings
("All pipe instances are busy", HTTP codes, exit codes) are log findings, never user-facing copy.
2026-11-09 08:51:31 -08:00
gramps 0fade9702b feat(ypp): session report derived from YouTube — no ledger, money off by default (TASK 3·20)
The task asked for a `RewardEvents` table plus a backfill. That would have contradicted the published
promise that no viewer, member or donor information is retained after the session ends, and it would
have meant owning a ledger that YouTube already keeps.

So the report is DERIVED on demand instead:

- `Services/SuperChatEventService.cs` — `superChatEvents.list`, parsed for both event types (a sticker
  must not be dropped), authorised by the existing `auth/youtube` scope, so no re-consent.
- `Models/SessionRewardSummary.cs` — rolls events up for the session window with **per-currency totals
  that are never summed together** (a $5 and a €5 are not $10). Nothing is written down.
- The YPP drawer gains a **THIS SESSION** section: counts are neutral and always shown when there were
  events; money appears only when the creator asks for it. `Show monetary metrics` is a drawer toggle,
  persisted, **off by default** — a creator who never monetized is never shown a $0.00.

Also: money is labelled as estimates (removed/refunded events are excluded and totals can be restated
for weeks, not days).

Tests: `SessionRewardReportTests` — both reward types parse, the window makes it a session rather than
a channel report, currencies stay separate, an empty session shows nothing, a sticker-only session
still counts. `BackOnAirTests` moves into the serialised `MainViewModelLayoutPath` collection: it builds
a MainViewModel directly and was flaking against parallel tests that swap the static layout path.

FAST gate: 557 passed, 0 failed, 0 warnings.
2026-11-05 15:38:32 -08:00
gramps 27f655c8de copy(price): the launch price ends on an event we control, not on Valve's queue
The promo read 'until April 1, 2027', which is a public promise tied to a review queue outside our
control: identity verification and the tax interview are in Valve's hands (they quote up to 30 business
days), and the store page and build reviews follow. If any of that slips, the date becomes untrue.

It now reads '30 days after the store page goes live — after that $99, locked for life', in the About
box and in commercial.md, so the claim cannot be wrong however long Valve takes.

Also recorded: the $100 Steam Direct fee is PAID (creator, 2026-11-05); a store page can be configured
once identity clears. The launch-price deadline no longer depends on Valve's calendar.
2026-11-05 15:13:20 -08:00
gramps 9611cee199 docs(steam): record the real onboarding state — a wait on Valve, not a task
The docs described Steamworks onboarding as pending work. It is pending VALVE: the partner account
exists, identity verification is under review, and the tax interview is in their queue with a quoted
turnaround of up to 30 business days. No app IDs are assigned, no store page exists, and Release
entitlement therefore still fails closed — by design.

Distribution.md §2 becomes a status table plus the critical path computed from Valve's own published
windows (30 business days verification → fee/app IDs → up to 12 business days store review → page
public ≥2 weeks before release → up to 12 business days build review). Earliest realistic launch:
early-to-mid February 2027, leaving ~7 weeks before the 2027-04-01 launch price ends.

Nothing in this blocks the code; commercial.md, TASKS 54 and HANDOFF now say so instead of implying
Steam is an outstanding coding task.
2026-11-05 15:08:53 -08:00
gramps 0de953ef66 feat(about): rebuild the About box as a conversion surface
Creator: the About box was "shitty" — an unreadable logo, a version number nobody asked for, copy that
insulted the creator, and buttons that looked like text. Rebuilt around one job: convert.

- **820×860 modal** (was 650×700) with a scrollable hub, and the logo at **520 px wide** instead of
  125 — the asset is 2816×1536, so it was being drawn as a thumbnail, not an icon problem.
- **Pre-Release pill** replaces "Version 1.0.0" (the assembly version stays in log lines). The pill is a
  button: it opens llamacasty.com until a Steam store page exists, so the link changes in one place.
- Tagline: "1-2-3, and go live to the world on YouTube!" The old "simple enough that even the most
  right-brained person can intuit it" is gone, and a test asserts it stays gone.
- The offer in three short blocks (free 30 fps · paid 60 fps · one-time, lifetime, no account /
  subscription / tracking), then the launch price and **one primary CTA**: "Unlock 60 fps — $79".
- Price: $79 until **April 1, 2027**, then $99 locked for life. The numbers live once, in
  `MainViewModel.Overlays`, and are recorded in `docs/ai/commercial.md`.
- **A paying customer is never sold to**: ownership collapses the price/CTA block and shows
  "Licence: owned via Steam — thank you."
- Secondary destinations (channel, Mastodon, coffee, licences) are one quiet row; footer is
  `© Llama Chile Shop`; the "Made by gramps" line is removed.
- `YtButtonSecondary` now carries a higher surface, a hairline border and a keyboard focus ring — they
  were the same tone as the panel behind them, which is why they read as plain text.

Test: `AboutHubTests.About_ShowsTheOfferToFreeUsers_AndNothingToSellToOwners` — tagline, pill, price,
CTA text, copyright, the removed lines stay removed, and the CTA block collapses when owned.
FAST gate: 540 passed, 0 failed, 0 warnings.
2026-11-05 13:00:57 -08:00
gramps 3fde7fcfe6 refactor(ui): alert rows are two lines of text — the heading line was redundant
Creator: system messages are limited to two lines; the label above the message ('STEAM ID NOT
PRESENT', 'STREAM') was repeating what the row already said and pushing the stack past its budget.

AttentionAlert loses Label; rows render Text only. The licence row is now exactly:
    Steam ID Limit: 30fps
    Click for More Info
(60fps with a close control once owned). Rows are identified by Id internally, so nothing in the UI
depends on the removed text. Tests updated to assert position/text rather than the heading.
2026-11-05 12:13:45 -08:00
gramps a80108dd96 feat(ui): left-panel alert stack — licence indicator pinned on top, events LIFO below
Creator design (2026-11-05). A stack of red rows at the top of the left panel, above both mode grids
so it shows while editing and while live, pushing the panel's content down.

- Row 1 is the **Steam licence indicator** and nothing displaces it. It reads as status, not a
  warning: "Entitlement 30 fps · Maximum 60 fps", both numbers derived from QualityOptions and
  HasProOutput. While higher tiers are unavailable it shows a **pin** (standing indicator, no close
  control); once ownership is verified the pin becomes a **close control** and the dismissal is
  persisted (`Steam.LicenceIndicatorDismissed`), permanent while ownership holds. Losing ownership
  restores it as pinned — a change of state, not a preference. Click → About.
- **Event rows stack beneath, newest first**, one row per episode (an episode advancing its state
  updates its own row rather than adding another). Each is dismissible. Click → the Logs tab opens
  with that event's sequence range painted red (`HighlightedLogFrom/To` + `LogInRangeConverter`; two
  numbers rather than a collection because the log list is virtualised and realised rows do not
  re-evaluate on membership changes).
- Replaces the old red panel under the quality picker, which is removed.
- The Steam diagnostic text loses "Contact the creator or support" for a neutral
  "Steam licensing is not configured in this build."

Tests: `AttentionAlertStackTests` — pinned first with the status text; owned → closable and the close
survives a reload but ownership loss restores it pinned; pinned cannot be dismissed; event rows LIFO
with in-place episode updates; clicking an event opens Logs and sets its range. AboutHub + Entitlement
tests updated for the removed panel and the new wording. FAST gate: 532 passed, 0 failed, 0 warnings.
2026-11-05 11:41:59 -08:00
gramps 0f4a035376 fix(preview): draw video layers, unlink Play from Loop, geometry last
Three reports from the creator, plus two bugs the real clip exposed.

1. A video layer was INVISIBLE in the preview. The pane's per-layer Image is Collapsed by
   default and switched on by a whitelist of DataTriggers; MediaSource matched none, so the clip
   decoded into a bitmap that nothing displayed. Same defect the alert box hit on 2026-09-26 and
   the scoreboard after it — now recorded at the top of docs/ai/ui-surface.md with a test that
   asserts the bound Image is Visible (`MediaPreviewDisplayTests`).

2. Play is no longer tied to Loop: the button is always enabled (creator ruling — Loop must not
   toggle Play). Loop still restarts a finished clip, which is the behaviour that was broken.

3. Geometry is now the LAST section of the properties list for every draggable element, with
   opacity at the top; the web source's duplicate geometry block is gone (it now uses the shared
   one, which had been sitting above the media panel).

4. The physical mouse-drag test is deleted. It injected real cursor movement, hijacked whatever
   machine ran it, and failed whenever the desktop was busy. The arithmetic it covered now lives
   in `Helpers/DragReorderMath.cs` — pure, and covered by `DragReorderMathTests` (threshold,
   up/down moves, no-op and out-of-range). The two persistence tests were already synthetic.

5. Real-clip check (creator's `test_greenscreen_moving_subject.mp4`, 1920×1080): the detector
   REJECTED it — a fully saturated green sits at value 1.0 and the detector's 0.98 brightness
   ceiling threw it away. White highlights are excluded by the saturation floor, which is the
   property that actually separates them, so the ceiling is gone. With it fixed, the clip is
   detected as green (high confidence), and keyed through our own pipe 97.8% of pixels go
   transparent. Measured: keying with the canonical #00B140 keys 0.0% of this clip — which is why
   the key colour comes from the measured screen.

Tests: 567 passed, 0 failed, clean build 0 warnings. The gate's scope check flags
`Assets/Videos/test_greenscreen_moving_subject.mp4` — the creator's working copy, deliberately not
committed; the fixture lives under ytLive.Tests/Fixtures.
2026-11-05 09:38:18 -08:00
gramps e37215d9c4 feat(record): fragment the MP4 so a crash cannot destroy the whole take
TASK 32·4. A plain MP4 keeps its index (moov) at the end, so a power cut or a killed
process left the entire recording unreadable even though every video byte was on disk.
The record block now carries
`-movflags +frag_keyframe+empty_moov+default_base_moof`: index first, a playable
fragment flushed at every keyframe, so an interrupted take plays to the last fragment.

Record block only — the FLV stream output has no index and never gets the flags.
Rename-on-stop is untouched.

Test: `Args_RecordBlock_IsFragmentedMp4_SoACrashKeepsTheFile` asserts the flags on the
record block and their absence from the streaming block.

Not yet proven on a real interrupted take (kill ffmpeg mid-recording, play the result);
the args test is by construction.

Docs: task-32 slice 4 ticked with that caveat, TASKS row, output.md record note, HANDOFF.
verify.sh: 550 passed (mouse-injection test excluded, desktop in use), 0 warnings.
2026-11-05 08:58:48 -08:00
gramps a814bcd502 fix(chat): stop painting what YouTube removed, and retire the local word filter
TASK 49 is retired (creator ruling): YouTube owns chat moderation. Its blocked-words filter and
hold-for-review run upstream of `liveChatMessages.list` — held messages are never returned
(https://stackoverflow.com/questions/74511524/) — so a second local list would duplicate a switch
the creator already owns, with a second set of false positives and a second support surface.

What we actually owed the platform, and had wrong:

- The parser added every item to the overlay. A deleted message arrives as a `tombstone` carrying
  no text at all ("it has since been deleted … only liveChatId, type and publishedAt are present" —
  https://developers.google.com/youtube/data/live/reference/rest/v3/liveChatMessages), so every
  deletion rendered as a BLANK chat row. `hasDisplayContent:false` messages and chat-mode events
  did the same.
- Deletion events (`messageDeletedEvent`/`messageRetractedEvent`/`userBannedEvent`) are documented
  but have not been delivered to clients for years (https://stackoverflow.com/questions/44346893/),
  so nothing is built on them; they are skipped if they ever arrive.
- Found while testing: Super Chat read `displayString`, but YouTube's field is `amountDisplayString`
  — every amount silently fell back to a recomposed string, which mangles non-dollar locales. The
  model's own comment said this was the reason the field exists.

Test: `ChatModerationParseTests` — tombstone, no-display-content, chat-mode events all produce no
row; a real Super Chat still parses its pre-rendered amount (constraint 4: reward fields are never
reinterpreted).

Docs: TASK 49 marked retired with the citations, the rule recorded in decisions.md, HANDOFF updated.
verify.sh: 550 passed, 0 failed, 0 warnings.
2026-11-05 08:39:46 -08:00
gramps ea24405ba2 feat(chroma): offer to remove a green or blue screen, and key it in the decode
TASK 64 slices D and E: the feature is usable end to end.

- Add a video file → one frame is probed (new `IChromaProbe`/`FfmpegChromaProbe`, one ffmpeg
  pass at 160x90, off the UI thread) and the layer gets an offer: "Looks like a green screen —
  Remove it", with the measured colour as a swatch.
- Accepting it turns the offer into settings (Amount / Softness / Fringe) that reach ffmpeg as
  `colorkey` + `despill` + `format=bgra`, so the key is computed in the pipe and both consumers
  (the shared preview bitmap and the compositor's frame) carry the same alpha.
- Media sessions are now keyed by **path + look** (`MainViewModel.MediaSessionKey`), so two
  layers with the same settings still share one decoder, and changing the look releases the old
  session and starts the new one. Removal, load reconciliation, loop and audio settings all move
  to the same key.
- Settings persist on the `Source` row (six ALTER TABLE guards + SELECT/INSERT), so a keyed layer
  comes back keyed.
- Panel section is plain creator words: Green/Blue Screen, Amount, Softness, Fringe.

Test: `ChromaKeyApplyTests` — the pipe asks ffmpeg to key (and does not when off); a green-screen
clip is offered, accepting it starts a keyed session, the layer's resolver then reads that session
rather than the old one; the setting survives a save.

Docs: TASK 64 D/E ticked with slice F (auto-apply setting + alert clips) left open; frame-pipeline
records the decode-option design; HANDOFF rewritten. verify.sh: 544 passed, 0 failed, 0 warnings.
2026-11-05 08:15:32 -08:00
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 db563c8644 docs(overlays): record the alert-clip/media decode overlap 2026-11-05 07:20:09 -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 4b7e798757 feat(ui): show YouTube-confirmed live state in top bar
Normal ON-AIR stays red until YouTube reports lifeCycleStatus=live, then turns green; TEST stays gold and stop restores the idle color.

Status-color reference: https://www.carbondesignsystem.com/building-blocks/core/patterns/status-indicators
2026-11-04 11:19:40 -08:00
gramps f921c0174b docs: reconcile Day 2 release policy and output tasks 2026-11-04 11:18:16 -08:00
gramps 6310f50c27 perf(video): move tier scaling to ffmpeg
The frame pump now submits crop-sized BGRA frames; FFmpeg scales them to final tier dimensions. Bundled scaler benchmark: 300 608x1080 to 1080x1920 frames in 0.435s (scaler-only).

Reference: https://ffmpeg.org/ffmpeg-filters.html#scale-1
2026-11-04 10:52:35 -08:00
gramps a6aea5bf26 feat(ui): color and acknowledge pinned log errors
Add independent AppLog message types and explicit Class 1/2 severity. Wrap and color log rows, pin Class 1 above Class 2, and let acknowledgement return entries to the current-launch timeline without changing file format or rotation.

Ref: https://logtape.org/manual/levels
2026-11-02 18:17:18 -08:00
gramps 221b81628a test(scoreboard): assert the themed pill switch 2026-11-02 11:14:15 -08:00
gramps 9fa06c67c2 feat(ui): add current-launch logs tab
Add Live Chat and Logs tabs to the non-edit left panel. Expose a bounded process-local AppLog feed so the newest messages appear first without importing earlier startup.log contents or changing file logging.

Ref: https://obsproject.com/forum/resources/understanding-obs-studio-log.473
2026-11-02 11:06:43 -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 6208cb9409 feat(ui): route locked tiers through About
Keep non-owner resolution choices visible but muted. Selecting one preserves the free tier and opens About; external navigation is only the explicit llamacasty.com link. Ref: https://streamlabs.com/desktop
2026-11-02 09:41:38 -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 8221bd6478 ui(properties): one typography hierarchy, pill switches, and tokens instead of hex
Creator directive 2026-11-01: the properties panels had 14 hand-rolled heading styles, 37
hand-rolled labels, four literal grey hex values, and 14 square checkboxes.

- PropertyHeader / PropertySubHeader / PropertyFieldLabel / PropertyHint in Themes/Controls.xaml
  now carry the whole hierarchy: category bright and 11px, labels and hints dim, size and weight
  doing the level. Colour groups the panel — bright for what you edit, dim for its labels and
  help — and no new palette entry was needed; DESIGN.md stays the only colour source.
- Every checkbox in the panel is a pill switch (PropertyPillCheck, 32x16 animated thumb).
  IsChecked semantics unchanged, so no binding moved.
- Literal #888/#666/#555 became OnSurfaceVariant / OnSurfaceDisabled.
- 13 headings, 2 sub-headers and 31 in-row labels now reference a style instead of repeating
  colour + size + alignment inline.

Controls audited for backing: every Button/Slider/ComboBox/CheckBox/ToggleButton/TextBox in the
panel has a Command or Click, a Value binding, an ItemsSource or Selected*, IsChecked, or Text —
no dead controls. Release 0 warnings; typography and token tests pass.
2026-11-01 15:51:33 -08:00
gramps 5d836aecbb docs(commercial): one price — $79 Steam DLC rising to $99, subscription retired
Creator ruling 2026-11-01. One SKU, one price, Steam does the selling.

- Pro is a one-time Steam DLC on the free app: $79 introductory, $99 on a stated date, announced
  where the price is shown and actually raised.
- The subscription is retired. The reasoning is in decisions.md: no service behind a monthly fee
  (no servers, no cloud, no content), ~1-3 subscribers per 1,000 installs against 5-8% churn, and
  it carried the most expensive code in the product.
- Polar goes dormant. Its customer-portal design is recorded, not built. The "freemium on Steam,
  convert on Polar, Valve gets nothing" proposal is rejected: in-app purchases must use the Steam
  Wallet, so it risks the listing to save ~$24 a sale.
- Entitlement collapses to `steamDlcOwned`. No lease, no key, no portal, no device limit; the
  greyed tier opens the Steam store overlay, so every purchase stays inside Valve's own surface.
- The site publishes the model and links the store page. It takes no payment and hosts no binaries.
- The $400 lifetime died with the subscription it anchored. Founding 200 died with the coupons and
  passes it needed.

Costs: $115 fixed year one; $79 nets $55.30 after Steam, so three sales cover the year.
2026-11-01 15:40:44 -08:00
gramps b00bf48b9d feat(alerts): sound byte under Built-in video, and alert audio that reaches the device
Creator request: a sound-byte browse + label under Built-in video, with an option to loop the
sound while the clip plays. The video itself is always one pass to completion.

- Source gains AlertAudioPath and AlertAudioLoop, persisted (column + migration + INSERT +
  SELECT + reader, the whole chain — that chain is where the blur crashed once).
- The sound byte plays through MediaPlayer on the default output device; MediaEnded re-seeks to
  zero while looping, and StopClip kills it so a tail cannot bleed into the next alert.
- The clip's OWN soundtrack moved off the mixer and onto the device as well
  (Services/Audio/AlertAudioOutput: one WaveOutEvent + buffered float provider).

Why the move: the creator asked that alert audio — the clip's track or the added sound byte —
appear in the desktop sound bar. Only audio that reaches the default device is picked up by the
WASAPI loopback that feeds both the meter and the stream. The old EnqueueAlertAudio route put the
sound in the stream but left the meter flat and the room silent.

Tests: the sound byte and its loop flag round-trip through SQLite, and a fresh alert layer starts
with no sound byte and no loop. Release 0 warnings.
2026-11-01 14:13:57 -08:00
gramps f8e4f2cf29 ui(audio): master meter centred and biggest; TRAX gets its own bay and meter
Creator rulings, 2026-11-01.

- Master strip is 44px (from 36) and its meter is now the largest on screen: 420x14 with 32
  segments, centred under the preview, with a 12px MASTER label — bigger than the 9px bay
  labels. L/R imbalance is a flag on the left, MONO and the session clock on the right.
- The kbps / fps / dropped counters are gone from that line: the live readouts already sit
  above the preview, and the duplicate was noise rather than telemetry.
- TRAX is its own bay (MIC | GAME | TRAX | LIP-SYNC) with its own meter bound to
  TraxMeterFraction. It lived inside the game bay with no ladder, which meant whether the bed
  was actually playing could not be answered from the dock — and the player already measures
  its own output.
- Lip-sync is narrow (0.45 star) since it is one slider and three presets; the three sound bays
  are equal width, so their meters keep the same relative size.

AudioDockLayout moves with it (156 = 112 + 44; collapsed 44) and the layout test is updated.
TraxBayTests now asserts the opposite of the old ruling: the bay HAS a meter, and the game bay
no longer carries it.
2026-11-01 13:48:28 -08:00
gramps bb51164f2f feat(blur): universal — the blur covers every layer, webcam included
The creator found the use case that reframes the feature: a moment of privacy while the webcam is
on. A blur that can only cover the desktop cannot do that.

- SceneCompositor applies the blur group AFTER every element is composited (still before the
  flash / social bar / ticker / zoom overlays), so it covers webcam, chat, alerts — anything. Its
  position in the layer list no longer affects the output, so it is added at the top and the
  reorder clamp is gone.
- Supersedes the earlier "blur sits below the brand" rule: a universal blur does obscure the logo
  when it covers it. Called out in frame-pipeline.md so it is not re-litigated.
- Preview mirrors it exactly: the blur host moved OUT of CanvasGrid to a sibling above it, so the
  VisualBrush samples every layer (and cannot sample itself). It used to sample only the backdrop,
  which is why a circle over the webcam would have shown sharp pixels in the editor and blurred
  ones in the stream.
2026-11-01 13:29:15 -08:00
gramps 71d0e87cac feat(blur): a privacy blur layer for the Live scene — boxes and circles over the desktop
Creator request 2026-11-01: a layer that hides part of the desktop from viewers. Box or circle,
drag/resize like any layer, one strength slider, several allowed.

**Z-order is policy, not preference.** `AddSource` inserts a blur directly above the background
and therefore below the brand logo, and `OnSceneElementsReordered` pushes any blur that ends up
above the brand back down. Nothing else in the app can move it into the brand's way.

**Overlap = the strongest single blur, never a stack.** `SceneCompositor.ApplyBlurLayers` collects
every visible blur the first time one is reached in the z-order, sorts them strongest-first, and
shares one coverage map: a pixel already covered by a stronger blur is skipped. Blurring
back-to-front would have produced something stronger than any slider setting allows, which is
exactly what was ruled out.

**One blur curve, shared with the webcam.** `RegionBlur` carries the constants the webcam blur
already used — downscale 4, radius 1–6 downscaled px, `RadiusFor(strength)` — and
`BackgroundEffectProcessor` now delegates to it instead of holding its own copy. The layer's
slider is the same 0–1 scale with the same 0.5 default, so "half strength" means the same blur in
both features. Cost is proportional to the blur's own rect, not the frame.

**Live scene only**, enforced in `AddSource` as well as the (+) menu (`CanAddBlur`), so a stale
menu item or a scripted call cannot plant one on BRB. `ElementKind.Dynamic`, so it is never baked
into the static base and every render path gets it — pump, preview, thumbnails, scene dump.

Persistence: `Source.BlurAmount` column + migration; shape rides the existing `ClipShape`.
Properties panel: Box | Circle plus the strength slider, gated on `IsBlurSelected`.

4 tests: outside the mask is byte-identical, inside loses its hard edges, a circle leaves its
box corners alone, and two identical stacked blurs equal one (the coverage map). Release builds
at 0 warnings.
2026-11-01 09:55:04 -08:00
gramps a35ec0c5f8 refactor(settings): a pull-out drawer, with Zoom as a category — and the menu moves to the wordmark
Creator directive, 2026-11-01. Settings stopped being a centred modal.

- **Drawer** (Controls/SettingsDrawer): hosted in PreviewPane's pull-out stack, 320px, the same
  animated-width pattern as Stream Settings / YPP / Socials / TEST, opened by the gear rail tab
  on a single left click. SettingsDrawerOpen is exclusive with the other four, both directions.
  This also removes the modal's MaxHeight-700 scroll problem for good: the drawer is the height
  of the preview.
- **Categories**: headings with controls indented beneath them. Zoom now owns *Zoom view shape*
  (Circle / Phone / Tablet) and *Zoom box follows*; Recordings, Brand logo and Scene transition
  are their own categories.
- **The app menu moved to the LlamaCasty wordmark** (right-click → Report Bug / Feature Request /
  About). Left-click still opens About. The gear tab no longer carries a menu at all, so clicking
  it does exactly one thing.

OverlayHost keeps only the Bug / Feature / About panels; its brand-logo handlers and the dev-panel
injection moved with the settings block. BrandLogoPreviewTests follows the control (same assertions,
new path). Release 0 warnings; 15 tests pass across brand logo, zoom, entitlement and preview
history.
2026-11-01 09:16:27 -08:00
gramps 15734a5477 feat(zoom): Ctrl closes the box, and a Follow pill that re-anchors the capture
Two creator directives, 2026-11-01.

**Ctrl-down dismisses an open zoom box.** Window-level PreviewKeyDown, so the same hand that
opened it can put it away. It cannot cancel itself at creation: Ctrl is already held before
the right-click that makes the box, so the keydown fires while nothing is open.

**Follow mode, default Anchor.** A PillToggle in App Settings ("Follow the box"), persisted
as Zoom.FollowBox. Off — the default — keeps the 2026-10-08 model: the capture stays on the
clicked point and dragging only moves the box over it. On, UpdateZoomMove re-anchors the
capture to the box centre (half the box's size, clamped per axis), so the magnifier shows
whatever it hovers. Flipping to Follow while the box is open snaps it immediately.

Three tests: anchor drags leave CaptureX alone, follow moves it to the expected quarter-offset
centre, and the setting round-trips through LayoutStore (tested against the store, not via two
view models — two live VMs fight over the temp DB, which is a fixture problem, not a bug).

Both new test classes share a collection tag: MainViewModel.LayoutPathOverride is static, and
xUnit runs classes in parallel, so without it the two view models collided (one failure in a
combined run, clean when run apart).

Release build 0 warnings; 14 tests pass across zoom, entitlement and preview history. Debug
build is blocked by the running app (PID 47388) — close it and the exe updates.
2026-11-01 08:51:28 -08:00
gramps 1d2d58f1d7 fix(zoom): the gesture now creates the box instead of only closing it
Creator report: Ctrl+right in edit mode and left-click in preview did nothing when
neither streaming nor recording. The bindings were correct; the action was not.

Two causes, both in the model rather than the handler:
- ActivateZoom opened with `if (!(IsLive || IsRecording)) return;` — the box could not be
  created outside a session at all.
- ToggleZoom only ever called DismissZoom, so "toggle" could turn it off and never on.

Now ToggleZoom(x, y) activates at the cursor or dismisses if already open, and the live-only
guard is gone — the magnifier is an editing aid as much as a live one. Both call sites pass
the canvas point, so the box still anchors on the click rather than the frame centre.

Debug 0 warnings; ZoomBox + ZoomMagnifier tests pass (3).
2026-11-01 08:43:17 -08:00
gramps 4b91760f92 fix(preview): Ctrl+right no longer opens the menu, and the social bar keeps its click
Two event-pairing bugs found by validating every handler instead of trusting the table.

1. Ctrl+right-click opened the context menu in edit mode. Cause: WPF's PopupControlService
   opens menus on right-button-UP, and only the DOWN event was being handled — so the zoom
   toggled and the menu appeared under it. Both events are now handled when Ctrl is down.
   (The creator saw this in edit mode; preview mode had the same latent bug.)

2. The social bar's left-click was dead in preview mode and dragged the layer beneath it in
   edit mode. Cause: PreviewGrid handles the TUNNELLING PreviewMouseLeftButtonDown, which
   fires before the bar's bubbling handler — and since preview-mode left-click now toggles
   zoom, that handler swallowed the bar's click too. The preview handler now steps aside when
   the point is inside the bar's bounds (SocialBarElement.RenderSize, translated to canvas).

Audited and left alone because they are correct: left-down → resize handle / element drag /
deselect; move → zoom drag before edit drag (by design); left-up → EndPreviewDrag; right-up
→ zoom-drag release then the Ctrl swallow.

Build Debug 0 warnings, 22 tests pass across ZoomBox, PreviewHistory and SocialBar.
2026-11-01 08:19:17 -08:00
gramps 6d1c982e26 feat(entitlement): IEntitlement seam — Steam DLC answers, everything fails closed
The first half of surge day 1. One question, "may this session emit Pro output?", with
one method on the interface so nothing downstream knows which rail proved it.

SteamEntitlement reads the lifetime SKU the way Valve intends: SteamAPI.Init() then
SteamApps.BIsDlcInstalled(dlcAppId). Four different failure paths all resolve to the
free tier rather than to Pro or to a crash — no DLC id configured, no Steam client,
steam_api64.dll missing (a throw on first touch), and simply not owned. A copied folder
run without Steam is the common case, not an exceptional one.

NotConfigured() is the shipping default until Steamworks creates the DLC app; it answers
false without ever touching the native library, so today's builds behave exactly as they
did before this commit.

HasProOutput is now IsPremium || DeveloperMode || rail, with an internal EntitlementOverride
test seam (internal, because a public setter here is a licence bypass). Four tests: no id
reads false, no client reads false without throwing, the fake reports what a test sets, and
the view model takes its answer from the rail.

Steamworks.NET 2024.8.0 added; the native DLL still comes from scripts/steam-sdk.sh, so the
csproj change is three lines and no formatting churn.
2026-11-01 08:13:25 -08:00
gramps 279eb08dea feat(preview): right-click is the menu, zoom is the single-handed gesture
Creator ruling 2026-11-01, and the reason is live-stream ergonomics: no two-handed
chords while a broadcast or recording is running.

- Edit mode: left selects / drags / resizes, right opens the context menu, Ctrl+right
  toggles the zoom box. Chords are acceptable here because editing is a stopped activity.
- Preview mode: left toggles the zoom box, right opens the context menu, and Ctrl+right
  is swallowed outright — handled rather than left to fall through, so Ctrl+right cannot
  open a menu by accident while one hand is on the mouse.

This also removes the old live behaviour where right-click activated the magnifier at the
cursor point (and dismissed it). Zoom in preview is now a plain toggle; if point-zoom is
wanted back it needs a one-button gesture, not a chord.

Build: Release 0 warnings. Debug is currently blocked by the running app holding the exe
(PID 47252) — close it and the Debug build copies normally.
2026-11-01 08:10:04 -08:00
gramps fdb27f0758 feat(ui): gear to the rail, EDIT on the panel edge, preview undo/redo
Three creator directives (2026-11-01), done ahead of the surge.

**Gear moved to the preview rail** — first tab, orange (Brush.Attention), gear icon
only. Left-click opens the same app menu the top bar gear owned (Settings / Bug /
Feature / About), right-click goes straight to App Settings; both behaviours moved
with it, so nothing lost an entry point. TopBar keeps the wordmark and EDIT.

**EDIT right edge aligned to the layers panel.** The work area is margin 8 + a 360px
column, the top bar is padding 16, so the shared edge is 352 inside the bar. TopBar
column 0 is now a fixed 352 grid with EDIT right-aligned in it — alignment by
arithmetic instead of by eye.

**Preview undo/redo: Ctrl+Z / Ctrl+Shift+Z.** Models/PreviewHistory snapshots element
REFERENCES plus geometry, visibility, order and membership — a deep clone would mirror
every property of a dozen element subclasses and quietly miss the next one added.
One snapshot per gesture (pushed at drag/resize start, never in the move loop), bounded
at 50, redo survives multiple undos, cleared on layout load. Gated on EditingActive:
undo while the preview shows program output would rewrite a scene nobody is looking at.

Honest scope, in the type's doc comment: layout edits only. Colour/opacity/border/font
edits are not captured yet, so Ctrl+Z after one of those restores the last layout change.

6 tests in PreviewHistoryTests; 19 pass across it and the neighbouring UI classes; Debug
and Release both build at 0 warnings.
2026-11-01 07:46:10 -08:00
gramps 27fc6fb507 feat(profile): the free budget is pixels and fps, enforced once — with a dev-only release hatch
The paid delta is output quality, so the rule needs to be measurable rather than a word:
921,600 pixels and 30 fps, fail-closed. Models/OutputProfile.cs is that rule as pure
policy — a permitted tier passes through untouched (Pro, or the free tier's own 720p30),
a disallowed one resolves to the best tier the budget allows and carries ITS bitrate, so
a capped user is never offered an 8 Mbps budget for a 720p frame.

Applied once, in BuildEncoderOptions. That is genuinely one place: the stream and the
local recording are two output blocks of ONE ffmpeg process fed by one EncoderOptions,
so there is no second encoder to forget. (commercial.md claimed "both encoders — never
two checks"; the code is stronger than the doc, and the doc is corrected.)

Developer override, as asked: a "Developer mode — all resolutions" checkbox in App
Settings, bound to MainViewModel.DeveloperMode, which feeds HasProOutput. It is not
hidden in a release build — Controls/DeveloperPanel's Page and Compile items are removed
when Configuration != Debug, and OverlayHost adds it at runtime inside #if DEBUG, so a
shipped exe has no BAML for it at all. The setter also refuses the value when
DevBuild.IsDevelopment is false, which is now the single source for that question (the
scoreboard partial had its own duplicate const).

Two traps worth their lines: a `const bool` dev flag makes `if (!Flag) return;` an
unreachable-code warning in one configuration or the other (CS0162), so DevBuild uses
static readonly; and a Condition on an ItemGroup is invisible to a test reading
item.Attribute("Condition") — read the parent.

Tests: 14 pass across OutputProfileTests, AssemblyIdentityTests and StorePackagingTests.
The invariant test is the one that matters: iterate every shipped tier under a free
entitlement and assert none emits more than the budget, so a future 4K tier fails here
instead of shipping to everyone. App builds Debug and Release at 0 warnings.
2026-11-01 07:11:49 -08:00
gramps abfab25685 build: the exe is LlamaCasty.exe, and the publish script proves the artifact
Renames AssemblyName to LlamaCasty before the first Steam depot — Steamworks is explicit
that renaming a live exe is painful, so this had to land first, not later.

Four assembly-qualified pack URIs carried the old name, not three: MainWindow (window
icon), OverlayHost (logo), RenameRecordingDialog and KeepBroadcastDialog. Pack URIs
resolve by assembly name at runtime, so a stale one compiles, passes every test and then
silently renders nothing the first time that view opens. AssemblyIdentityTests guards
both halves — the assembly name, and no source XAML still naming the legacy assembly.
Packaging/Package.appxmanifest and package-msix.sh carry the exe name too.

scripts/publish.sh replaces the by-hand publish: single-file win-x64, ReadyToRun,
ffmpeg beside the exe, and it refuses to finish if ffmpeg, LICENSE, PRIVACY.md or
THIRD-PARTY-NOTICES.txt are missing, or if a test assembly leaked in.

That last guard exists because the first publish was wrong in a way no test could see:
PublishSingleFile + IncludeNativeLibrariesForSelfExtract bundled the libav*.dll family
into the exe — a 9-file output with no ffmpeg folder and a 182 MB binary, so the locator
would have found nothing at runtime. ExcludeFromSingleFile="true" on the content keeps
the folder a folder. Diffing against a control publish without the single-file flags
found it; guessing at item globs wasted two attempts first (recorded in MyMistakes.md).

Measured: 127 MB exe + 129 MB ffmpeg = 257 MB depot. Tests: AssemblyIdentity,
StorePackaging, BuildStamp, BundledFonts all pass; app builds with 0 warnings.
2026-11-01 07:01:50 -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 57ef9e7a5e docs(commercial): the claim page was never needed, signing waits for revenue, and support is a filter
Four things derived this session superseded what the plan said twenty-four hours earlier,
and the docs are corrected where each one died:

**Licensing needs no server.** Polar's customer-portal endpoints authenticate on the key
itself plus the public organization_id; the org access token is only needed to create
products. Channel binding rides in `conditions: {channel_id}`, re-checked on every
validate — so the "claim page" (a hosted OAuth flow that wrote the UC id onto the key)
and the key database it implied are deleted. Device management, key rotation and
subscription auto-revocation are Polar's; four states, one lease, no callback endpoint.

**Signing is deferred until two subscribers.** It never removes the day-one SmartScreen
warning (EV's instant-bypass died in 2024), so buying it before revenue buys only the
publisher line. Renamed to Azure Artifact Signing, $9.99/mo Basic, and it requires a
paid Azure subscription. Year-one fixed cost drops from ~$135 to ~$15.

**The Store account is free.** Microsoft waived the $19 individual fee and removed the
$99 company fee in May 2026, which makes the Store the only zero-cost route to a
Microsoft-signed install — the free answer to SmartScreen, noted beside the paid one.

**Support is a filter, not a promise.** Email, best effort, no SLA, refunds on request
without argument; no training service, no helpdesk. A repeated question becomes a
training video on @llamacasty, which is content and monetization instead of a ticket.
GitHub issues on the releases repo are the deliberate friction.

Also settled: binaries live in a second Gitea repo (`llamacasty-releases`) push-mirrored
to a public GitHub repo — issues and assets public, code private, one publish direction
(a git mirror carries refs, not GitHub Release assets); `@llamacasty` is
standard-verified with intermediate features, so live streaming, >15 min uploads and
custom thumbnails work today and no SIM or ID is needed; and OAuth comes out of Testing
mode by publishing the app (100-user lifetime cap, no 7-day token death).

TASKS.md now opens with the four-day surge slice — artifact shape, output gate,
entitlement seam, real-machine day — because those are what the first public release
waits on. HANDOFF.md rewritten. MyMistakes.md gains the Polar recipe and the
"verify means three different things" entry. MARCOM.md gains the support→content loop
and the surface roles.
2026-10-31 10:08:05 -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 c72b61b61e docs(decisions): the site publishes the model, not the terms
Records the split so neither side drifts: the app repo holds the real numbers (720p30 free, $10/mo
or $400 lifetime, Store individual account) and the site holds only the promise (free forever, every
feature unlocked, a paid edition at launch).
2026-10-30 16:16:31 -07:00
gramps 54de9c3dfb docs(decisions): Store route re-opened via an Individual developer account
Creator ruling 2026-10-30: the Microsoft Store is the distribution target again, through an
Individual (not Company) developer account — which is what made the 2026-10-22 withdrawal necessary
in the first place, and why no legal entity is now required. The publisher line shows the verified
individual name; distribution work stays parked until the account exists, and the site's 'coming
soon to the Microsoft Store' copy is accurate again.
2026-10-30 16:15:28 -07:00