diff options
Diffstat (limited to 'docs/superpowers/plans/2026-10-23-companion-music.md')
| -rw-r--r-- | docs/superpowers/plans/2026-10-23-companion-music.md | 589 |
1 files changed, 589 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-23-companion-music.md b/docs/superpowers/plans/2026-10-23-companion-music.md new file mode 100644 index 0000000..534d029 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-companion-music.md @@ -0,0 +1,589 @@ +# Companion music Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The companion DJs on a track start/change, grooves instead of looking bored while music plays, and says per-artist lines edited in a settings page. + +**Architecture:** `Mood.js` (pure) gains `djAt`, `music`, `groove` inputs. `Lines.js` gains artist defaults and a substring matcher, with its app-set helpers taking a defaults argument. `Companion.qml` reads the existing `Player` singleton (`modules/sound/Player.qml`) for track changes and drives a generic frame cycle. `PhrasesPage.qml` gets three text properties so a new `ArtistsModule.qml` reuses it, saving through new `Prefs.companionArtists`. + +**Tech Stack:** Quickshell 0.3.1 QML, QML JS libraries tested with node, FLUX.2 klein frames via `gen-frames`. + +Spec: `docs/superpowers/specs/2026-10-23-companion-music-design.md`. + +Rules from AGENTS.md that apply: no `flatMap`, `Object.fromEntries`, `matchAll` in QML JS; never name a property after the enclosing id; editing `shell.qml` needs `./reload.sh desktop` from the user's terminal, never from an agent; process is `qs` (`pgrep -cx qs`). Commits are GPG-signed, end with the Co-Authored-By line, and carry the reasoning. + +All commands run from the repo root `~/Programming/GIT/quickshell`. + +--- + +### Task 1: Mood.js, dj and groove + +**Files:** +- Modify: `desktop/companion/Mood.js` (T table, `pick`) +- Test: `desktop/companion/test_mood.js` + +- [ ] **Step 1: Write the failing tests** + +In `test_mood.js`, extend the `calm` defaults (the object starting `leaving: false, now: NOW, ...`) with `djAt: 0, music: false, groove: ""` on the line that holds `singAt: 0,`: + +```js + repliedAt: 0, clicks: { count: 0, at: 0 }, mockAt: 0, typingAt: 0, wakeAt: 0, admireAt: 0, singAt: 0, + djAt: 0, music: false, groove: "", +``` + +In `ladder`, insert directly after `["sing", { singAt: NOW - 10 }],`: + +```js + ["dj", { djAt: NOW - 10 }], +``` + +After the `// Idle levels and their frames.` block, add: + +```js +// Music while bored: she grooves with the picked frame instead, whistling +// when nothing was picked; yawning and lying down are unchanged. +assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true, groove: "headbang" })), { state: "groove", frame: "headbang" }); +assert.deepStrictEqual(M.pick(calm({ idle: 1, music: true })), { state: "groove", frame: "whistle" }); +assert.deepStrictEqual(M.pick(calm({ idle: 1, groove: "headbang" })), { state: "bored", frame: "sleepy" }); +assert.strictEqual(state({ idle: 2, music: true }), "yawn"); +assert.strictEqual(state({ idle: 3, music: true }), "lying"); +// Music while awake is the rest pose; the DJ is only the burst. +assert.strictEqual(state({ music: true }), "base"); +assert.deepStrictEqual(M.pick(calm({ djAt: NOW - 10 })), { state: "dj", frame: "dj" }); +``` + +After `assert.strictEqual(state({ singAt: NOW - M.T.singHold }), "base");` add: + +```js +assert.strictEqual(state({ djAt: NOW - M.T.djHold }), "base"); +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `node desktop/companion/test_mood.js` +Expected: AssertionError on the ladder (`everything from dj down`). + +- [ ] **Step 3: Implement** + +In `Mood.js`, in `T` after `singHold`: + +```js + djHold: 6000, // behind the decks when a track starts or changes +``` + +In `pick`, after the `sing` line: + +```js + if (s.now - s.djAt < T.djHold) return st("dj", "dj"); +``` + +Replace `if (s.idle === 1) return st("bored", "sleepy");` with: + +```js + // Music turns boredom into a groove picked by Companion.qml; the + // deeper idle stages still win, so music does not keep her awake. + if (s.idle === 1) return s.music ? st("groove", s.groove || "whistle") : st("bored", "sleepy"); +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `node desktop/companion/test_mood.js` +Expected: `ok` + +- [ ] **Step 5: Commit** + +```bash +git add desktop/companion/Mood.js desktop/companion/test_mood.js +git commit -m "companion: dj and groove states in Mood + +A DJ burst ranks just below the headphones sing, so both arriving together +sings first. Music turns only the first idle stage into a groove: yawning +and lying down still win, so music does not keep her awake and the +screensaver handoff is unchanged. The groove pick is an input, keeping +pick() pure. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 2: Lines.js, artist lines and groove lines + +**Files:** +- Modify: `desktop/companion/Lines.js` +- Test: `desktop/companion/test_lines.js` + +- [ ] **Step 1: Write the failing tests** + +In `test_lines.js`, change the return list to: + +```js + "\nreturn { lines, apps, artists, monologue, appMap, appList, pickApp, pickArtist, pick, toMap };")(); +``` + +Before `console.log("ok");` add: + +```js +// Artists: a case-insensitive substring of what MPRIS reports, first +// matching key wins, empty phrases skipped. +assert.strictEqual(L.pickArtist("Linkin Park", null, 0), L.artists["linkin park"][0]); +assert.strictEqual(L.pickArtist("LINKIN PARK, Jay-Z", null, 0), L.artists["linkin park"][0]); +assert.strictEqual(L.pickArtist("Nobody", null, 0), ""); +assert.strictEqual(L.pickArtist("", null, 0), ""); +assert.strictEqual(L.pickArtist("Air, Beck", { air: ["a"], beck: ["b"] }, 0), "a"); +assert.strictEqual(L.pickArtist("Airbourne", { air: ["", "a"] }, 0), "a"); +assert.strictEqual(L.pick("artist:Eminem", 0, null, null), L.artists.eminem[0]); +assert.strictEqual(L.pick("artist:Eminem", 0, null, { eminem: ["mine"] }), "mine"); +assert.strictEqual(L.pick("groove", 0), L.lines.groove[0]); + +// The same list helpers serve the artist set. +assert.strictEqual(L.appMap(null, L.artists), L.artists); +assert.strictEqual(L.appList(null, L.artists).length, Object.keys(L.artists).length); +assert.deepStrictEqual(L.toMap(L.appList(null, L.artists)), L.artists); +``` + +- [ ] **Step 2: Run, expect failure** + +Run: `node desktop/companion/test_lines.js` +Expected: `ReferenceError: artists is not defined` + +- [ ] **Step 3: Implement** + +In `Lines.js`, add to `lines` after `headphones`: + +```js + groove: ["This one's good.", "...Don't tell 9S.", "Again. Play it again."], +``` + +After the `apps` object: + +```js +// The lines for an artist she knows, keyed by a lowercased name matched as +// a substring of the reported artist, so "daft punk" also catches +// collaborations. The Companion artists page edits them into +// Prefs.companionArtists, which then replaces this whole set. +var artists = { + "linkin park": ["In the end, it doesn't even matter.", "Crawling in my skin... I know this one."], + "evanescence": ["Wake me up inside. ...Operator, don't laugh."], + "gigi d'agostino": ["L'amour toujours. I'll fly with you.", "...My feet are moving on their own."], + "eminem": ["Lose yourself. Understood.", "He talks faster than Pod."], +}; +``` + +Replace `appMap` and `appList` with versions taking the defaults (apps when omitted, so existing callers keep working): + +```js +// A set in effect: saved when it is a plain object, the defaults (apps +// unless given) otherwise. A malformed saved value must not leave her mute. +function appMap(saved, defaults) { + return saved && typeof saved === "object" && !Array.isArray(saved) ? saved : (defaults || apps); +} + +// A set as an ordered list of {cls, lines}, for the settings pages; cls is +// the key, a window class or an artist. +function appList(saved, defaults) { + const m = appMap(saved, defaults); + const keys = Object.keys(m); + const out = []; + for (let i = 0; i < keys.length; i++) + out.push({ cls: keys[i], lines: (m[keys[i]] || []).slice() }); + return out; +} +``` + +Replace `pickApp` and `pick` with a shared non-empty filter, plus `pickArtist`: + +```js +function nonEmpty(all) { + const l = []; + for (let i = 0; i < (all || []).length; i++) + if (typeof all[i] === "string" && all[i].trim() !== "") l.push(all[i]); + return l; +} + +function at(l, roll) { return l.length ? l[Math.min(l.length - 1, Math.floor(roll * l.length))] : ""; } + +// A line for an app class, empty strings skipped; "" when the class has none. +function pickApp(cls, saved, roll) { return at(nonEmpty(appMap(saved)[cls]), roll); } + +// A line for the playing artist: the first key that is a case-insensitive +// substring of it and has a non-empty line. ponytail: substring, so a +// short key like "air" also matches "Airbourne"; whole words if that bites. +function pickArtist(artist, saved, roll) { + const a = String(artist || "").toLowerCase(); + if (a === "") return ""; + const m = appMap(saved, artists); + const keys = Object.keys(m); + for (let i = 0; i < keys.length; i++) { + const l = nonEmpty(m[keys[i]]); + if (keys[i] !== "" && l.length && a.indexOf(keys[i].toLowerCase()) !== -1) return at(l, roll); + } + return ""; +} + +// A line for a reaction kind, or, with an "app:" or "artist:" prefix, for +// that class or artist. +function pick(kind, roll, saved, savedArtists) { + if (kind.slice(0, 4) === "app:") return pickApp(kind.slice(4), saved, roll); + if (kind.slice(0, 7) === "artist:") return pickArtist(kind.slice(7), savedArtists, roll); + return at(lines[kind] || [], roll); +} +``` + +- [ ] **Step 4: Run, expect pass** + +Run: `node desktop/companion/test_lines.js && node desktop/companion/test_mood.js` +Expected: `ok` twice. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/companion/Lines.js desktop/companion/test_lines.js +git commit -m "companion: artist and groove lines + +Artist keys match as a case-insensitive substring of the MPRIS artist, so +collaborations and feat. credits still hit; the cost is a short key +over-matching, marked as a ponytail ceiling. appMap/appList take their +defaults so the settings page can serve artists with the same helpers. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 3: Prefs.companionArtists + +**Files:** +- Modify: `desktop/Prefs.qml` (after `resetCompanionApps`, parse block near line 155, `onLoadFailed` near line 172) + +- [ ] **Step 1: Add the property and setters** after `resetCompanionApps()`'s closing brace: + +```qml + // The companion's artist lines as saved, keyed by lowercased artist, or + // null for the defaults in companion/Lines.js. The Companion artists + // page edits it. + property var companionArtists: null + function setCompanionArtists(map) { + root.companionArtists = map; + root.write(Object.assign({}, root.doc, { companionArtists: map })); + } + function resetCompanionArtists() { + root.companionArtists = null; + const d = Object.assign({}, root.doc); + delete d.companionArtists; + root.write(d); + } +``` + +- [ ] **Step 2: Parse it** after the `root.companionApps = ca && ...` line: + +```qml + const cr = parsed.companionArtists; + root.companionArtists = cr && typeof cr === "object" && !Array.isArray(cr) ? cr : null; +``` + +- [ ] **Step 3: Reset on load failure**: in the `onLoadFailed` line, after `root.companionApps = null;` insert ` root.companionArtists = null;`. + +- [ ] **Step 4: Check the shell reloads cleanly** + +Run: `qs log -p desktop --log-times | tail -5` +Expected: a `Reloading configuration` / `Configuration Loaded` after the edit's mtime, no errors. If no reload line, see AGENTS.md "Hot reload sometimes does not fire". + +- [ ] **Step 5: Commit** + +```bash +git add desktop/Prefs.qml +git commit -m "prefs: companionArtists, saved like companionApps + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 4: PhrasesPage text properties and ArtistsModule + +**Files:** +- Modify: `desktop/modules/companion/PhrasesPage.qml` (properties near line 27, intro at line 101, placeholder at line 132, button at line 180) +- Create: `desktop/modules/companion/ArtistsModule.qml` +- Modify: `desktop/shell.qml:91` + +- [ ] **Step 1: Parametrise the page.** After `required property QtObject owner`, add: + +```qml + // What the cards are keyed by; the defaults are the app set's, so + // PhrasesModule passes nothing. + property string intro: "When focus moves to a program she recognizes, she says one of its lines at random, a third " + + "of the time and under her 30 s line cooldown. The class is Hyprland's window class, " + + "lowercased (hyprctl activewindow shows it). Save applies at once." + property string keyHint: "window class, e.g. firefox" + property string addText: "Add app" + // The saved set, for Reset's enabled state; null means the defaults. + property var saved: Prefs.companionApps +``` + +Replace the intro `Small`'s `text:` (the three-line string) with `text: page.intro`, the card field's `placeholder: "window class, e.g. firefox"` with `placeholder: page.keyHint`, `Button { text: "Add app"; ...}` with `Button { text: page.addText; onClicked: page.addApp() }`, and in the Reset button's `enabled:` replace `Prefs.companionApps !== null` with `page.saved !== null`. + +- [ ] **Step 2: Create `ArtistsModule.qml`** (copy the GPLv2 header from `PhrasesModule.qml` lines 1-10 verbatim, then): + +```qml +import QtQuick +import "../.." +import "../../companion/Lines.js" as Lines + +// The lines the companion says when a track by an artist she knows starts, +// matched as a case-insensitive substring of the player's artist. Same +// draft, Save and Reset as PhrasesModule, on Prefs.companionArtists and +// Lines.artists; the page's cls field holds the artist. +Module { + id: am + name: "artists" + label: "Companion artists" + // nf-fa-music, present in Inconsolata Nerd Font. + icon: "" + settings: true + group: "Companion" + keywords: ["companion", "artist", "music", "song", "track", "line", "say", "2b"] + searchItems: am.shown.map((a, i) => ({ text: a.cls + ": " + a.lines.join(" / "), key: "app:" + i })) + + property var draft: null + property bool resetting: false + readonly property var shown: am.draft ?? Lines.appList(Prefs.companionArtists, Lines.artists) + readonly property bool valid: am.shown.every(a => String(a.cls).trim() !== "" + && (a.lines || []).some(l => String(l).trim() !== "")) + dirty: am.draft !== null + + function edit(list) { am.draft = list; am.resetting = false; } + function reset() { am.draft = Lines.appList(null, Lines.artists); am.resetting = true; } + function save() { + if (am.resetting) Prefs.resetCompanionArtists(); + else Prefs.setCompanionArtists(Lines.toMap(am.draft)); + am.discard(); + } + function discard() { am.draft = null; am.resetting = false; } + + page: Component { + Page { + title: "Companion artists" + actions: Component { + SaveBar { dirty: am.dirty; valid: am.valid; note: "every artist needs a name and a phrase" + onSave: am.save(); onDiscard: am.discard() } + } + PhrasesPage { + width: parent.width + owner: am + intro: "When a track by an artist she knows starts or changes, she says one of their lines at " + + "random, under her 30 s line cooldown. The name matches any part of the player's artist, " + + "ignoring case, so \"daft punk\" also catches \"Daft Punk, Pharrell Williams\". " + + "Save applies at once." + keyHint: "artist, e.g. linkin park" + addText: "Add artist" + saved: Prefs.companionArtists + } + } + } +} +``` + +- [ ] **Step 3: Register it** in `desktop/shell.qml`, after `PhrasesModule {},`: + +```qml + ArtistsModule {}, +``` + +- [ ] **Step 4: Verify.** `shell.qml` changed, so ask the user to run `./reload.sh desktop` from their terminal (never from a tool call). Then `qs log -p desktop | tail -20` must show no errors, and the user checks by eye: Settings > Companion artists lists the four seeds; Companion phrases still reads as before. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/companion/PhrasesPage.qml desktop/modules/companion/ArtistsModule.qml desktop/shell.qml +git commit -m "companion: artist lines page + +PhrasesPage takes its intro, key hint and add-button text, so the artist +set reuses it rather than a copy that would drift. ArtistsModule mirrors +PhrasesModule on Prefs.companionArtists and Lines.artists. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 5: Companion.qml, track changes, groove pick, frame cycles + +**Files:** +- Modify: `desktop/companion/Companion.qml` + +- [ ] **Step 1: Import the player.** After `import ".."` add `import "../modules/sound"`. + +- [ ] **Step 2: Inputs.** After `property double singAt: 0`: + +```qml + property double djAt: 0 + property string groove: "" +``` + +- [ ] **Step 3: Groove pick and bored line.** In `onIdleChanged`, replace the `else if (root.idle === 1 && root.wasIdle === 0) { ... }` branch with: + +```qml + } else if (root.idle === 1 && root.wasIdle === 0) { + root.pickGroove(); + if (Player.playing) root.say("groove"); + else if (Mood.monologue("bored", root.lastMonologueAt, t, Math.random())) root.startMonologue(); + else root.say("bored"); + } +``` + +- [ ] **Step 4: Track changes.** After the headphones block (`onPhonesConnectedChanged`), add: + +```qml + // What is playing, from the sound module's player (it already drops the + // playerctld and browser-integration duplicates). A new artist+title + // while playing is a track start or change: she DJs for Mood.T.djHold + // and says the artist's line if she knows them. Paused reads "", and + // resuming the same track matches lastTrack, so it does not DJ again. + readonly property string track: Player.playing ? Player.artist + "\n" + Player.title : "" + property string lastTrack: "" + onTrackChanged: { + if (root.track === "" || root.track === root.lastTrack) return; + root.lastTrack = root.track; + root.djAt = Date.now(); + root.now = root.djAt; + root.say("artist:" + Player.artist); + } + // Music starting while she is already bored needs a groove too. + readonly property bool music: Player.playing + onMusicChanged: if (root.music && root.idle === 1) root.pickGroove() + function pickGroove() { + const g = ["whistle", "headbang", "airguitar"]; + root.groove = g[Math.floor(Math.random() * g.length)]; + } +``` + +- [ ] **Step 5: Feed Mood.** In the `Mood.pick({...})` snapshot, change `singAt: root.singAt, monologue: root.monologue, idle: root.idle,` to: + +```qml + singAt: root.singAt, djAt: root.djAt, music: root.music, groove: root.groove, + monologue: root.monologue, idle: root.idle, +``` + +- [ ] **Step 6: Artist lines reach say.** In `say()`, change the pick to: + +```qml + const line = Lines.pick(kind, Math.random(), Prefs.companionApps, Prefs.companionArtists); +``` + +- [ ] **Step 7: Frame cycles.** After the `waveFlip` Timer, add: + +```qml + // Frames Mood names that are really a loop: the frame names its cycle + // and how fast it turns. Everything else shows as named. + readonly property var cycles: ({ + dj: { frames: ["dj", "dj-scratch"], ms: 450 }, + headbang: { frames: ["headbang-up", "headbang-down"], ms: 220 }, + }) + property int cycleIndex: 0 + readonly property var cycle: root.cycles[root.mood.frame] ?? null + onCycleChanged: root.cycleIndex = 0 + Timer { + interval: root.cycle?.ms ?? 1000 + repeat: true + running: root.cycle !== null && !root.away + onTriggered: root.cycleIndex = (root.cycleIndex + 1) % root.cycle.frames.length + } + readonly property string frame: root.leaving ? root.waveCycle[root.waveIndex] + : root.cycle ? root.cycle.frames[root.cycleIndex % root.cycle.frames.length] : root.mood.frame +``` + +Change the `Figure`'s `source:` to: + +```qml + source: `file://${root.art}/${root.outfit}/${root.frame}.png` +``` + +- [ ] **Step 8: Verify the load.** + +Run: `node desktop/companion/test_mood.js && node desktop/companion/test_lines.js && qs log -p desktop --log-times | tail -10` +Expected: `ok`, `ok`, a reload after the edit with no `TypeError`/`ReferenceError`. Then ask the user to play a track: she should show `base` (frames not generated yet, falls back) for 6s, and say an artist line for one of the seeds; pause and resume must not repeat it. + +- [ ] **Step 9: Commit** + +```bash +git add desktop/companion/Companion.qml +git commit -m "companion: DJ on track change, groove while bored with music + +Reads the sound module's Player, which already filters the MPRIS +duplicates. The track key is artist+title while playing, so a pause and +resume of the same track does not DJ again; a shell start with music on +DJs once, which is harmless. Frame loops are a small table keyed by the +frame Mood names, so dj and headbang need no special case in Mood. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +--- + +### Task 6: Frames and docs + +**Files:** +- Modify: `desktop/companion/frames.txt` +- Modify: `desktop/companion/README.md` + +- [ ] **Step 1: Dress frames.** In `frames.txt`, after `dress/sing <- ...`, add: + +``` +# Music: behind the decks on a track start or change, and the grooves +# that replace boredom while music plays. +DECK = A small black DJ console with two turntables and a mixer stands in front of her at waist height, hiding her legs. +dress/dj <- dress/base : ${DECK} Both gloved hands rest on the turntables, head nodding, a small smile; she wears black over-ear headphones. ${EYES} ${KEEP} +dress/dj-scratch <- dress/dj : Change only her arms: the hand on the left side of the image scratches a record, the other holds one headphone cup to her ear. Everything else exactly the same. ${KEEP} +dress/whistle <- dress/base : Change only her expression: lips puckered, whistling, a couple of small black musical notes floating beside her mouth. ${POSE} ${EYES} ${KEEP} +dress/headbang-up <- dress/base : She headbangs: head tipped back, hair flying up, eyes shut, fists clenched at her sides. ${BARE} ${KEEP_BARE} +dress/headbang-down <- dress/headbang-up : Change only her head: tipped sharply forward and down, hair thrown forward over her face. Everything else exactly the same. ${KEEP_BARE} +dress/airguitar <- dress/base : She plays an invisible air guitar: one hand strumming at her hip, the other fretting in the air to the side, knees bent, eyes shut, mouth open in a grin. Nothing in her hands. ${BARE} ${KEEP_BARE} +``` + +`KEEP`, `EYES`, `BARE`, `KEEP_BARE` and `LEOTARD` are defined at the top of the file; `DECK` only has to precede its uses, as `SING` does. + +- [ ] **Step 2: Leotard frames.** After the last `leotard/` line add: + +``` +leotard/dj <- dress/dj : ${LEOTARD} +leotard/dj-scratch <- dress/dj-scratch : ${LEOTARD} +leotard/whistle <- dress/whistle : ${LEOTARD} +leotard/headbang-up <- dress/headbang-up : ${LEOTARD} +leotard/headbang-down <- dress/headbang-down : ${LEOTARD} +leotard/airguitar <- dress/airguitar : ${LEOTARD} +``` + +- [ ] **Step 3: Check the parse.** + +Run: `desktop/companion/gen-frames --list` +Expected: the 12 new outputs listed (and nothing else new), no parse error. + +- [ ] **Step 4: README.** In `desktop/companion/README.md`, add to the Files table row for `Lines.js` "and per-artist lines", and add an Inputs bullet after the Sony headphones one: + +``` +- What is playing is `modules/sound/Player.qml`, which already drops the + playerctld and browser-integration duplicates. A new artist+title while + playing makes her DJ for `Mood.T.djHold`, 6 s (`dj` and `dj-scratch` + cycling), and say the artist's line if one matches: lowercased keys in + `Lines.artists`, matched as a substring, edited on Settings > Companion + artists into `Prefs.companionArtists`. Pause and resume of the same + track is not a change. While music plays, the first idle stage is a + groove instead of bored: `whistle`, `headbang` (`headbang-up`/`-down` + cycling) or `airguitar`, picked at random, with a `groove` line; + yawning and lying down are unchanged. +``` + +- [ ] **Step 5: Commit** + +```bash +git add desktop/companion/frames.txt desktop/companion/README.md +git commit -m "companion: music frames to generate, and the README + +Twelve frames, dress and leotard. Prompts are a first draft for klein; the +user generates them, and until then each falls back to base. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +- [ ] **Step 6: Hand off art.** Tell the user: start sd-webui with FLUX.2 klein, run `desktop/companion/gen-frames`, reroll with `--seed N <output>` as needed, record failures in `recipe.txt`. |
