retake-demos 0.14.2 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -122,6 +122,12 @@ a person who has heard it. It only fixes the voice and rate of narration that
122
122
  already exists, and strips any `viewport`/`scale` pin, which silently beats the
123
123
  preset and is the one way two takes of the same app come out different shapes.
124
124
 
125
+ Voices are names: `andrew`, `ava`, `brian`, `emma`, `aria`, `ryan` (Edge,
126
+ online) and `heart`, `michael`, `aiden` (local: they run on the person's Mac
127
+ and the first narrated render downloads the model). The voice a person picked
128
+ when they first opened Retake is the routine's voice. If the routine says
129
+ `silent` and they want narration, ask which voice — never guess an id.
130
+
125
131
  Writing the steps, the captions and the story is still entirely your job.
126
132
  `demos/style.md` is the taste that needs judgement; the routine is the part
127
133
  that only needs to be the same every time.
package/README.md CHANGED
@@ -324,7 +324,7 @@ On a new workspace, `retake ui` opens the first-demo flow and writes the manifes
324
324
 
325
325
  **Scenes** are the spine: `{ action: scene, label, caption }` at each beat. They take their timestamps from the actual run, so captions land on the frames they describe even when the app took 3 seconds this time and 11 the last. **Labels must be unique** — `thumbnail`, `until`, the stills and the proof log all address scenes by label, so two scenes called the same thing make every one of those ambiguous. `validate` rejects it.
326
326
 
327
- **Two kinds of take.** `mode: demo` (the default) proves how something works — a client walkthrough, a PR clip, a lesson — and keeps everything but the product out of the frame. `mode: launch` presents the product in public and may carry a title card, music, emphasis and a branded ending. `validate` warns if a demo is carrying launch furniture. **The launch cut.** `compressIdle: true` shows the app's long waits as ~1.5 s each (your own pacing `wait`s are never touched); `voiceover: true` reads the captions aloud (edge-tts, `pipx install edge-tts` once). **Local voices** run on an Apple Silicon Mac and send nothing anywhere: `retake routine set voice heart` (Kokoro, female, ~300 MB), `michael` (Kokoro, male) or `aiden` (Qwen3, ~1.8 GB). They need [uv](https://docs.astral.sh/uv/) (`brew install uv`); the model downloads once, the first time a video is narrated. Kokoro reads demo words the demo way ("press re-CORD", "deh-mohz") and every local voice is levelled to Edge's loudness; `typing: brisk` makes typed text land fast with the pauses carrying the meaning. All render-time except typing. **Music.** `music: track.mp3` (or `{ file, gainDb, fadeOutMs }`) mixes a bed under the video at render time, looped or trimmed to fit and faded out at the end. Bring your own track — it ships inside your video, so it must be one you may use (CC0 or licensed). **Cards and callouts.** `intro:`/`outro:` add a title card in Retake's own look — rendered at render time, so changing a word is a re-render, and the intro's settled frame is written as `cover.png`, the poster. `{ action: callout, selector, label, ms }` draws an animated ring and label around an element while the take holds — recorded coordinates, drawn at render, moving with the camera. Sparingly: the captions carry the story.
327
+ **Two kinds of take.** `mode: demo` (the default) proves how something works — a client walkthrough, a PR clip, a lesson — and keeps everything but the product out of the frame. `mode: launch` presents the product in public and may carry a title card, music, emphasis and a branded ending. `validate` warns if a demo is carrying launch furniture. **The launch cut.** `compressIdle: true` shows the app's long waits as ~1.5 s each (your own pacing `wait`s are never touched); `voiceover: true` reads the captions aloud (edge-tts, `pipx install edge-tts` once). **Local voices** run on an Apple Silicon Mac and send nothing anywhere: `retake routine set voice heart` (Kokoro, female), `michael` (Kokoro, male) or `aiden` (Qwen3). They need [uv](https://docs.astral.sh/uv/) (`brew install uv`). The first narrated video downloads the voice engine and model once: about 2.6 GB for Kokoro, about 4 GB for Aiden. Kokoro reads demo words the demo way ("press re-CORD", "deh-mohz") and every local voice is levelled to Edge's loudness; `typing: brisk` makes typed text land fast with the pauses carrying the meaning. All render-time except typing. **Music.** `music: track.mp3` (or `{ file, gainDb, fadeOutMs }`) mixes a bed under the video at render time, looped or trimmed to fit and faded out at the end. Bring your own track — it ships inside your video, so it must be one you may use (CC0 or licensed). **Cards and callouts.** `intro:`/`outro:` add a title card in Retake's own look — rendered at render time, so changing a word is a re-render, and the intro's settled frame is written as `cover.png`, the poster. `{ action: callout, selector, label, ms }` draws an animated ring and label around an element while the take holds — recorded coordinates, drawn at render, moving with the camera. Sparingly: the captions carry the story.
328
328
 
329
329
  **Seeds** put the app in a known state before the camera: write a JSON file, run JS in the page, or run a shell command. **Setup** is the stuff that runs before recording and is trimmed off the front — logins live there. **`tempo`** speeds the finished video up or down at render time; **`lock`** names a shared resource so two demos that touch the same backend don't record over each other.
330
330
 
@@ -0,0 +1,14 @@
1
+ /** Where a demo's flags live and how they are read — on its own so that
2
+ `verify` (which checks them) and `flags` (which raises them, and asks
3
+ `verify` to judge) do not import each other. */
4
+ import fs from "node:fs";
5
+ export const flagsPath = (manifestFile) => manifestFile.replace(/\.(ya?ml|json)$/i, "") + ".flags.json";
6
+ export function readFlags(manifestFile) {
7
+ try {
8
+ const v = JSON.parse(fs.readFileSync(flagsPath(manifestFile), "utf8"));
9
+ return Array.isArray(v.flags) ? v.flags : [];
10
+ }
11
+ catch {
12
+ return [];
13
+ }
14
+ }
package/dist/ext/flags.js CHANGED
@@ -23,16 +23,8 @@ import { pickJudge, pool } from "./judge.js";
23
23
  import { judgeWith } from "./verify.js";
24
24
  import { clipFor } from "./clip.js";
25
25
  import { noteCheck } from "./checked.js";
26
- export const flagsPath = (manifestFile) => manifestFile.replace(/\.(ya?ml|json)$/i, "") + ".flags.json";
27
- export function readFlags(manifestFile) {
28
- try {
29
- const v = JSON.parse(fs.readFileSync(flagsPath(manifestFile), "utf8"));
30
- return Array.isArray(v.flags) ? v.flags : [];
31
- }
32
- catch {
33
- return [];
34
- }
35
- }
26
+ export { flagsPath, readFlags } from "./flag-store.js";
27
+ import { flagsPath, readFlags } from "./flag-store.js";
36
28
  function writeFlags(manifestFile, flags) {
37
29
  fs.writeFileSync(flagsPath(manifestFile), JSON.stringify({
38
30
  _readme: "Things you flagged on this demo. Each one is also an `expect:` on its scene in the manifest — that is what actually gets checked. Delete an entry here and in the manifest to stop watching it.",
package/dist/ext/heal.js CHANGED
@@ -64,7 +64,7 @@ export function findOrphans(outRoot, demosDir) {
64
64
  * came from, and this is not cosmetic. A take recorded with `--from` or
65
65
  * `--until` keeps the PARENT demo's name in its used manifest, so restoring
66
66
  * two fragments of one demo produced three files all declaring
67
- * `name: avex-full-flow` — and the window, which keys on that name, showed the
67
+ * `name: booking-full-flow` — and the window, which keys on that name, showed the
68
68
  * same demo three times. It looked broken because it was.
69
69
  */
70
70
  export function healOrphans(orphans, demosDir) {
package/dist/ext/sense.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * `verify` judges one frame: is the button readable, is the card inside its
5
5
  * box. This judges the whole run: what went IN against what came OUT.
6
6
  *
7
- * The bug that produced it: an Avex demo entered two legs — SGN→SIN and
7
+ * The bug that produced it: a flight-booking demo entered two legs — SGN→SIN and
8
8
  * SIN→SGN — and quoted a single price of 17,400. Nothing caught it. Every
9
9
  * step passed, every frame looked fine, and the take recorded both halves:
10
10
  *
@@ -23,7 +23,7 @@
23
23
  import fs from "node:fs";
24
24
  import path from "node:path";
25
25
  import { askAsync, pickJudge, pool, readJson, why as short } from "./judge.js";
26
- import { readFlags } from "./flags.js";
26
+ import { readFlags } from "./flag-store.js";
27
27
  import { NO_INTENT_NOTE, intentBlock } from "./intent.js";
28
28
  import { tag } from "./evidence.js";
29
29
  import { noteCheck } from "./checked.js";
package/dist/media-bin.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { bundledFfmpeg } from "./self-repair.js";
3
3
  let cachedBin;
4
4
  /** Resolve the bundled media binary first, then fall back to PATH. */
@@ -21,3 +21,18 @@ export function ffmpegBin() {
21
21
  }
22
22
  return (cachedBin = "ffmpeg");
23
23
  }
24
+ /** ffmpeg -i probe: duration and stream size, no ffprobe needed. */
25
+ export function probe(file) {
26
+ const out = String(spawnSync(ffmpegBin(), ["-hide_banner", "-i", file], { encoding: "utf8" }).stderr);
27
+ const d = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(out);
28
+ const v = /Video:.*?\s(\d{2,5})x(\d{2,5})[\s,].*?(\d+(?:\.\d+)?)\s*fps/.exec(out) ?? /Video:.*?\s(\d{2,5})x(\d{2,5})/.exec(out);
29
+ if (!d)
30
+ throw new Error(`could not read duration of ${file}`);
31
+ return {
32
+ duration: Number(d[1]) * 3600 + Number(d[2]) * 60 + Number(d[3]),
33
+ width: v ? Number(v[1]) : 0,
34
+ height: v ? Number(v[2]) : 0,
35
+ fps: v && v[3] ? Number(v[3]) : 0,
36
+ };
37
+ }
38
+ export const videoDuration = (file) => probe(file).duration;
@@ -383,7 +383,7 @@ server.registerTool("env", {
383
383
  }
384
384
  });
385
385
  server.registerTool("routine", {
386
- description: "Call this FIRST in any session. The workspace's locked standard — output format, captions, voice and speaking rate, music — and, when the product has one, its full recording brief: where the app runs, how to seed it, the selectors that hold, and what breaks. READ THIS BEFORE DRAFTING and do not set `preset`, `layout`, `captions`, `viewport`, `scale`, `voiceover.voice`, `voiceover.ratePercent` or `music` yourself — they are not yours to choose, and choosing them is how two videos from the same workspace come back looking and sounding different. Write the steps and the captions; call routine_apply when the manifest exists.",
386
+ description: "Call this FIRST in any session. The workspace's locked standard — output format, captions, voice and speaking rate, music — and, when the product has one, its full recording brief: where the app runs, how to seed it, the selectors that hold, and what breaks. READ THIS BEFORE DRAFTING and do not set `preset`, `layout`, `captions`, `viewport`, `scale`, `voiceover.voice`, `voiceover.ratePercent` or `music` yourself — they are not yours to choose, and choosing them is how two videos from the same workspace come back looking and sounding different. Write the steps and the captions; call routine_apply when the manifest exists. Voices are names (andrew, ava, brian, emma, aria, ryan online; heart, michael, aiden local on the person's Mac): if the routine says silent and they want narration, ask which one.",
387
387
  inputSchema: {},
388
388
  annotations: READ_ONLY,
389
389
  }, async () => {
package/dist/record.js CHANGED
@@ -19,7 +19,7 @@ import { execFileSync, spawn } from "node:child_process";
19
19
  import YAML from "yaml";
20
20
  import { recordPage, moveCursorToPoint } from "testreel";
21
21
  import { expandEnv, signInAt, resolve } from "./manifest.js";
22
- import { ffmpegBin, videoDuration } from "./render.js";
22
+ import { ffmpegBin, videoDuration } from "./media-bin.js";
23
23
  import { assessActionProof, proofMatches, snapshotActionProof, waitForActionProof } from "./action-proof.js";
24
24
  /** What the *recording* depends on — nothing that render can change afterwards.
25
25
  A scene's caption, hold and camera zoom are render-time, so editing them must
package/dist/render.js CHANGED
@@ -24,7 +24,7 @@ import { bandHeightFor, maxCharsFor, wrap } from "./captions.js";
24
24
  import { renderCard, renderCalloutOverlay, renderPoster, renderTitledCover } from "./cards.js";
25
25
  import { describeIdle, planIdle, warpFilterArgs, warpTake } from "./pace.js";
26
26
  import { holdFilterArgs, holdTake, planHolds } from "./hold.js";
27
- import { DEFAULT_VOICE, LOCAL_VOICE, LOCAL_VOICE_VERSION, audioSeconds, cueGroups, cuesPerParagraph, cutPoints, silences, synthesize, synthesizeWithCues } from "./voice.js";
27
+ import { DEFAULT_VOICE, LOCAL_VOICE, LOCAL_VOICE_VERSION, voiceId, audioSeconds, cueGroups, cuesPerParagraph, cutPoints, silences, synthesize, synthesizeWithCues } from "./voice.js";
28
28
  import { expandEnv, resolve, Manifest } from "./manifest.js";
29
29
  import { ffmpegBin } from "./media-bin.js";
30
30
  export { ffmpegBin } from "./media-bin.js";
@@ -41,7 +41,7 @@ export function renderHash(m, take) {
41
41
  }
42
42
  catch {
43
43
  return null;
44
- } }) : null], idle: m.compressIdle, vo: m.voiceover, voEngine: typeof m.voiceover === "object" && LOCAL_VOICE.test(m.voiceover.voice) ? LOCAL_VOICE_VERSION : undefined, music: m.music ? [m.music, (() => { try {
44
+ } }) : null], idle: m.compressIdle, vo: m.voiceover, voEngine: typeof m.voiceover === "object" && LOCAL_VOICE.test(voiceId(m.voiceover.voice)) ? LOCAL_VOICE_VERSION : undefined, music: m.music ? [m.music, (() => { try {
45
45
  const f = typeof m.music === "string" ? m.music : m.music.file;
46
46
  const st = fs.statSync(path.resolve(f));
47
47
  return [st.size, Math.round(st.mtimeMs)];
@@ -67,21 +67,8 @@ function ff(args, log) {
67
67
  log?.(`$ ffmpeg ${args.join(" ")}`);
68
68
  execFileSync(ffmpegBin(), ["-hide_banner", "-loglevel", "error", "-y", ...args], { stdio: "inherit" });
69
69
  }
70
- /** ffmpeg -i probe: duration and stream size, no ffprobe needed. */
71
- export function probe(file) {
72
- const out = String(spawnSync(ffmpegBin(), ["-hide_banner", "-i", file], { encoding: "utf8" }).stderr);
73
- const d = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(out);
74
- const v = /Video:.*?\s(\d{2,5})x(\d{2,5})[\s,].*?(\d+(?:\.\d+)?)\s*fps/.exec(out) ?? /Video:.*?\s(\d{2,5})x(\d{2,5})/.exec(out);
75
- if (!d)
76
- throw new Error(`could not read duration of ${file}`);
77
- return {
78
- duration: Number(d[1]) * 3600 + Number(d[2]) * 60 + Number(d[3]),
79
- width: v ? Number(v[1]) : 0,
80
- height: v ? Number(v[2]) : 0,
81
- fps: v && v[3] ? Number(v[3]) : 0,
82
- };
83
- }
84
- export const videoDuration = (file) => probe(file).duration;
70
+ export { probe, videoDuration } from "./media-bin.js";
71
+ import { probe } from "./media-bin.js";
85
72
  /** Whether a file carries a sound track at all. */
86
73
  export function probeHasAudio(file) {
87
74
  return /Stream #\d+:\d+.*: Audio:/.test(String(spawnSync(ffmpegBin(), ["-hide_banner", "-i", file], { encoding: "utf8" }).stderr));
package/dist/routine.js CHANGED
@@ -20,6 +20,7 @@ import path from "node:path";
20
20
  import YAML from "yaml";
21
21
  import { execFileSync } from "node:child_process";
22
22
  import * as mediaBin from "./media-bin.js";
23
+ import { VOICES, voiceId } from "./voice.js";
23
24
  import { z } from "zod";
24
25
  export const STYLES = {
25
26
  showcase: {
@@ -53,24 +54,7 @@ export const STYLES = {
53
54
  workspace; it is not a per-video decision and never was. The language rides
54
55
  on the voice — a Vietnamese routine is a Vietnamese voice id, not a separate
55
56
  setting that would have to agree with this one. */
56
- export const VOICES = {
57
- ryan: { id: "en-GB-RyanNeural", note: "British, easy, positive. The one the Capture demos use." },
58
- andrew: { id: "en-US-AndrewNeural", note: "Warm, confident, unhurried." },
59
- ava: { id: "en-US-AvaNeural", note: "Expressive and friendly, a little closer in." },
60
- brian: { id: "en-US-BrianNeural", note: "Casual and sincere — least like a narrator." },
61
- emma: { id: "en-US-EmmaNeural", note: "Clear and conversational, good over fast UI." },
62
- aria: { id: "en-US-AriaNeural", note: "A news read: even, flat, no colour." },
63
- // Local: these run on this Mac (Apple Silicon) and send nothing anywhere.
64
- heart: { id: "kokoro:af_heart", note: "Local (Kokoro). Warm, natural, female. First use downloads ~300 MB." },
65
- michael: { id: "kokoro:am_michael", note: "Local (Kokoro). Steady, male. Shares Heart's ~300 MB download." },
66
- aiden: { id: "qwen:aiden", note: "Local (Qwen3). Natural, male. First use downloads ~1.8 GB." },
67
- };
68
- /** A routine's `voice` is a name above, "silent", a raw edge-tts voice id
69
- * (`vi-VN-HoaiMyNeural`) for a language the named set does not cover, or a
70
- * raw local id (`kokoro:af_bella`, `qwen:ryan`). */
71
- export function voiceId(voice) {
72
- return VOICES[voice]?.id ?? voice;
73
- }
57
+ export { VOICES, voiceId } from "./voice.js";
74
58
  /* ─── the file ─────────────────────────────────────────────────────────────── */
75
59
  export const RoutineSchema = z.object({
76
60
  style: z.enum(["showcase", "fullscreen", "mobile"]).default("showcase"),
@@ -165,6 +149,20 @@ export function routineFile(demosDir) {
165
149
  export function starter() {
166
150
  return { use: "default", routines: { default: RoutineSchema.parse({ note: "The house standard. Change it here, not per video." }) } };
167
151
  }
152
+ /** The narration chosen in the window's first run, kept in the routine so
153
+ every later agent reads it — not only the one that was told. It sets
154
+ whose voice reads a script; nothing is narrated until one is approved. */
155
+ export function rememberNarration(demosDir, narration) {
156
+ if (narration === "silent")
157
+ return;
158
+ const voice = { edge: "andrew", kokoro: "heart", aiden: "aiden" }[narration];
159
+ const file = readRoutines(demosDir) ?? starter();
160
+ const merged = RoutineSchema.safeParse({ ...(file.routines[file.use] ?? {}), voice });
161
+ if (merged.success) {
162
+ file.routines[file.use] = merged.data;
163
+ writeRoutines(demosDir, file);
164
+ }
165
+ }
168
166
  export function readRoutines(demosDir) {
169
167
  const f = routineFile(demosDir);
170
168
  if (!fs.existsSync(f))
@@ -52,13 +52,25 @@ export function bundledFfmpeg() {
52
52
  }
53
53
  if (!bin)
54
54
  return null;
55
- if (fs.existsSync(bin))
55
+ if (runs(bin))
56
56
  return bin;
57
+ // Missing, or there but not working: an install cut off mid-download
58
+ // leaves a partial file that is not executable, and "it exists" used to be
59
+ // enough to hand it to every render (EACCES, nothing could render).
57
60
  const installer = path.join(path.dirname(require.resolve("ffmpeg-static/package.json")), "install.js");
58
61
  if (!fs.existsSync(installer))
59
62
  return null;
60
- say("… downloading ffmpeg (one time, ~45 MB — npm skipped it at install)");
63
+ const broken = fs.existsSync(bin);
64
+ if (broken)
65
+ fs.rmSync(bin, { force: true });
66
+ say(broken ? "… ffmpeg was only partly downloaded — fetching it again (~45 MB)" : "… downloading ffmpeg (one time, ~45 MB — npm skipped it at install)");
61
67
  spawnSync(process.execPath, [installer], { cwd: path.dirname(installer), stdio: ["ignore", "ignore", "inherit"] });
62
- return fs.existsSync(bin) ? bin : null;
68
+ return runs(bin) ? bin : null;
69
+ }
70
+ /** Whether this is an ffmpeg that actually starts. */
71
+ function runs(bin) {
72
+ if (!fs.existsSync(bin))
73
+ return false;
74
+ return spawnSync(bin, ["-version"], { stdio: "ignore", timeout: 15_000 }).status === 0;
63
75
  }
64
76
  patchRecorder();
package/dist/ui/chat.html CHANGED
@@ -352,6 +352,9 @@
352
352
  .ob-operator-question button, .ob-retry { min-height:42px; border:1px solid var(--ink); border-radius:11px; padding:0 15px; color:#fff; background:var(--ink); font-weight:600; cursor:pointer; }
353
353
  .ob-operator-question button.secondary { color:var(--ink); background:#fff; }
354
354
  .ob-operator-result { margin-top:18px; }
355
+ .ob-next { margin:18px auto 0; max-width:520px; color:var(--muted); font-size:13px; line-height:1.6; }
356
+ .ob-next code { display:inline-block; margin-top:4px; padding:3px 8px; border-radius:7px; background:#fff; border:1px solid var(--rule); color:var(--ink); font:12.5px ui-monospace,Menlo,monospace; }
357
+ .ob-copy { border:1px solid var(--rule2); border-radius:8px; background:#fff; padding:3px 9px; font:inherit; font-size:12px; cursor:pointer; }
355
358
  .ob-operator-result.bad { color:var(--bad); }
356
359
  @media (max-width:700px) {
357
360
  .wrap.onboarding-only { padding:14px 12px 36px; }
@@ -526,10 +529,10 @@
526
529
  <span class="vicon" aria-hidden="true">∞</span><b>Edge</b><small>Online. Small install, many voices, and fast generation. Your script goes to Microsoft's service.</small><span class="tag">Tap to hear</span>
527
530
  </button>
528
531
  <button class="voice-card" type="button" data-voice="aiden" data-sample="aiden" role="radio" aria-checked="false">
529
- <span class="vicon" aria-hidden="true">A</span><b>Aiden</b><small>Local Qwen voice. The most natural one, and the biggest: about 1.8 GB. Runs on your Mac, so your script never leaves it.</small><span class="tag">Tap to hear</span>
532
+ <span class="vicon" aria-hidden="true">A</span><b>Aiden</b><small>Local Qwen voice. The most natural one, and the biggest: about 4 GB, once. Runs on your Mac, so your script never leaves it.</small><span class="tag">Tap to hear</span>
530
533
  </button>
531
534
  <button class="voice-card" type="button" data-voice="kokoro" data-sample="kokoro" role="radio" aria-checked="false">
532
- <span class="vicon" aria-hidden="true">K</span><b>Kokoro</b><small>Local and much smaller: about 300 MB, many voices. Runs on your Mac, so your script never leaves it.</small><span class="tag">Tap to hear</span>
535
+ <span class="vicon" aria-hidden="true">K</span><b>Kokoro</b><small>Local and smaller: about 2.6 GB once, many voices. Runs on your Mac, so your script never leaves it.</small><span class="tag">Tap to hear</span>
533
536
  </button>
534
537
  </div>
535
538
  <div class="voice-demo" id="voice-demo"><video id="voice-demo-video" controls playsinline preload="metadata"></video><small id="voice-demo-label"></small></div>
@@ -642,8 +645,8 @@ function renderOnboarding() {
642
645
  consent.classList.toggle('show', needsConsent);
643
646
  box.checked = onboardingState.consent;
644
647
  if (onboardingState.voice === 'edge') $('voice-consent-copy').textContent = 'I understand that Edge sends the narration script to Microsoft’s online speech service.';
645
- if (onboardingState.voice === 'aiden') $('voice-consent-copy').textContent = 'I understand that Aiden needs a one-time local model download of about 1.8 GB, the first time a video is narrated. Nothing downloads on this screen.';
646
- if (onboardingState.voice === 'kokoro') $('voice-consent-copy').textContent = 'I understand that Kokoro needs a one-time local model download of about 300 MB, the first time a video is narrated. Nothing downloads on this screen.';
648
+ if (onboardingState.voice === 'aiden') $('voice-consent-copy').textContent = 'I understand that Aiden needs a one-time download of about 4 GB (its voice engine and model), the first time a video is narrated. Nothing downloads on this screen.';
649
+ if (onboardingState.voice === 'kokoro') $('voice-consent-copy').textContent = 'I understand that Kokoro needs a one-time download of about 2.6 GB (its voice engine and model), the first time a video is narrated. Nothing downloads on this screen.';
647
650
  $('onboard-next').disabled =
648
651
  (onboardingStep === 1 && !normalUrl($('onboard-url').value)) ||
649
652
  (onboardingStep === 2 && brandRead.state === 'reading') ||
@@ -798,7 +801,12 @@ function finishOnboardingOperator(data) {
798
801
  if (ok) {
799
802
  localStorage.removeItem(ONBOARD_KEY);
800
803
  sessionStorage.removeItem(ONBOARD_SIGNED_KEY);
801
- card.innerHTML = `<div class="ob-success" data-operator-done><div class="ob-icon" aria-hidden="true">✓</div><h2>Your first draft is ready</h2><p class="ob-lead">${esc(data.summary || 'Open it from the library.')}</p><button type="button" data-open-first>Open first draft</button></div>`;
804
+ card.innerHTML = `<div class="ob-success" data-operator-done><div class="ob-icon" aria-hidden="true">✓</div><h2>Your first draft is ready</h2><p class="ob-lead">${esc(data.summary || 'Open it from the library.')}</p><button class="ob-retry" type="button" data-open-first>Open first draft</button>${onboardingState.voice && onboardingState.voice !== 'silent' ? `<p class="ob-next">To add a voiceover, tell your coding agent: <code data-next-ask>Add a voiceover to my Retake demo “${esc(data.demo)}”, using the voice in my Retake routine.</code> <button type="button" class="ob-copy" data-copy-next>Copy</button></p>` : ''}</div>`;
805
+ const copyNext = card.querySelector('[data-copy-next]');
806
+ if (copyNext) copyNext.onclick = async () => {
807
+ try { await navigator.clipboard.writeText(card.querySelector('[data-next-ask]').textContent); copyNext.textContent = 'Copied'; }
808
+ catch { copyNext.textContent = 'Select and copy it'; }
809
+ };
802
810
  card.querySelector('[data-open-first]').onclick = async () => {
803
811
  onboardingSubmitted = false;
804
812
  await drawDemos();
package/dist/ui/server.js CHANGED
@@ -51,7 +51,7 @@ import { startOffer, startApp, listeningPorts } from "../appserver.js";
51
51
  import { PKG_ROOT, PROJECT_ROOT, VERSION, entry, isDemoFile } from "../paths.js";
52
52
  import { SECRET_NAME, missingSecrets, writeEnvFile } from "../env.js";
53
53
  import { captureMatchesTake, readPreviewReceipt } from "../preview.js";
54
- import { STYLES, VOICES, RoutineSchema, environmentFor, readRoutines, starter, voiceId, writeRoutines } from "../routine.js";
54
+ import { STYLES, VOICES, RoutineSchema, environmentFor, readRoutines, rememberNarration, starter, voiceId, writeRoutines } from "../routine.js";
55
55
  import { loadEnvSpec } from "../recording-env.js";
56
56
  import { describeSchedule, readSchedule, scheduleLoaded } from "../schedule.js";
57
57
  import { synthesize, voiceCachePath, LOCAL_VOICE, localModelReady } from "../voice.js";
@@ -1236,13 +1236,17 @@ export function serve(port) {
1236
1236
  if (!b.describe?.trim())
1237
1237
  return json(res, 400, { error: "say what the demo should show" });
1238
1238
  const narration = ["silent", "edge", "aiden", "kokoro"].includes(b.narration) ? b.narration : "silent";
1239
+ // The choice outlives this session: it goes into the routine, which is
1240
+ // what any later agent reads. A video still stays silent until someone
1241
+ // approves a script; this only settles whose voice reads it.
1242
+ rememberNarration(DEMOS, narration);
1239
1243
  const sound = narration === "silent"
1240
1244
  ? "Make the first draft silent and do not add captions."
1241
1245
  : narration === "edge"
1242
- ? "Make the first draft silent and do not add captions. The person chose Edge for the final narration. Do not synthesize it until they approve both the recording and one continuous script."
1246
+ ? "Make the first draft silent and do not add captions. The person chose Edge for the final narration; the workspace routine now says `voice: andrew`. Do not synthesize it until they approve both the recording and one continuous script."
1243
1247
  : narration === "aiden"
1244
- ? "Make the first draft silent and do not add captions. The person chose the local Aiden voice for the final narration: `voiceover.voice: qwen:aiden` (a routine says `voice: aiden`). It runs on their Mac; its first use downloads a ~1.8 GB model. Do not narrate until they approve the recording and one continuous script, and say the download is coming before the first narrated render."
1245
- : "Make the first draft silent and do not add captions. The person chose the local Kokoro voice for the final narration: `voiceover.voice: kokoro:af_heart` (a routine says `voice: heart`). It runs on their Mac; its first use downloads a ~300 MB model. Do not narrate until they approve the recording and one continuous script, and say the download is coming before the first narrated render.";
1248
+ ? "Make the first draft silent and do not add captions. The person chose the local Aiden voice for the final narration: `voiceover.voice: qwen:aiden` (the workspace routine now says `voice: aiden`). It runs on their Mac; its first use downloads about 4 GB (the voice engine and the model, once). Do not narrate until they approve the recording and one continuous script, and say the download is coming before the first narrated render."
1249
+ : "Make the first draft silent and do not add captions. The person chose the local Kokoro voice for the final narration: `voiceover.voice: kokoro:af_heart` (the workspace routine now says `voice: heart`). It runs on their Mac; its first use downloads about 2.6 GB (the voice engine and the model, once). Do not narrate until they approve the recording and one continuous script, and say the download is coming before the first narrated render.";
1246
1250
  const describe = `${b.describe.trim()}\n\n${sound}`;
1247
1251
  const prov = pickProvider();
1248
1252
  const which = prov?.name === "codex" ? "codex" : prov?.name === "claude-code" ? "claude-code" : null;
package/dist/voice.js CHANGED
@@ -27,11 +27,36 @@ export function voiceCachePath(text, voice, ratePercent = 0) {
27
27
  const key = createHash("sha256").update(`${v}\0${ratePercent}\0${text}`).digest("hex");
28
28
  return path.join(root, `${key}.mp3`);
29
29
  }
30
+ export const VOICES = {
31
+ ryan: { id: "en-GB-RyanNeural", note: "British, easy, positive. The one the Capture demos use." },
32
+ andrew: { id: "en-US-AndrewNeural", note: "Warm, confident, unhurried." },
33
+ ava: { id: "en-US-AvaNeural", note: "Expressive and friendly, a little closer in." },
34
+ brian: { id: "en-US-BrianNeural", note: "Casual and sincere — least like a narrator." },
35
+ emma: { id: "en-US-EmmaNeural", note: "Clear and conversational, good over fast UI." },
36
+ aria: { id: "en-US-AriaNeural", note: "A news read: even, flat, no colour." },
37
+ // Local: these run on this Mac (Apple Silicon) and send nothing anywhere.
38
+ heart: { id: "kokoro:af_heart", note: "Local (Kokoro). Warm, natural, female. First use downloads ~2.6 GB (voice engine and model)." },
39
+ michael: { id: "kokoro:am_michael", note: "Local (Kokoro). Steady, male. Shares Heart's download." },
40
+ aiden: { id: "qwen:aiden", note: "Local (Qwen3). Natural, male. First use downloads ~4 GB (voice engine and model; ~1.8 GB if Kokoro is already set up)." },
41
+ };
42
+ /** A routine's `voice` is a name above, "silent", a raw edge-tts voice id
43
+ * (`vi-VN-HoaiMyNeural`) for a language the named set does not cover, or a
44
+ * raw local id (`kokoro:af_bella`, `qwen:ryan`). */
45
+ export function voiceId(voice) {
46
+ // The names a person or an agent reaches for: a name above, Kokoro's own
47
+ // id (`af_heart` — what an agent guessed first, and Retake handed to Edge),
48
+ // or the full form. Anything else is taken as an edge-tts id.
49
+ if (VOICES[voice])
50
+ return VOICES[voice].id;
51
+ if (/^[a-z][fm]_[a-z]+$/.test(voice))
52
+ return `kokoro:${voice}`;
53
+ return voice;
54
+ }
30
55
  /** Voices that run on this Mac: `kokoro:<voice>` and `qwen:<speaker>`. */
31
56
  export const LOCAL_VOICE = /^(kokoro|qwen):[a-z_]+$/;
32
57
  /** Bump when local pronunciation or levelling changes: it re-renders them. */
33
58
  export const LOCAL_VOICE_VERSION = "local-voice-1";
34
- const LOCAL_MODEL = { kokoro: { repo: "models--mlx-community--Kokoro-82M-8bit", size: "~300 MB" }, qwen: { repo: "models--mlx-community--Qwen3-TTS-12Hz-0.6B-CustomVoice-8bit", size: "~1.8 GB" } };
59
+ const LOCAL_MODEL = { kokoro: { repo: "models--mlx-community--Kokoro-82M-8bit", size: "~2.6 GB with the voice engine, once" }, qwen: { repo: "models--mlx-community--Qwen3-TTS-12Hz-0.6B-CustomVoice-8bit", size: "~4 GB with the voice engine, once" } };
35
60
  /** The local voice engine: Retake's script under uv, with the MLX audio stack. */
36
61
  export function localEngine(voice, platform = process.platform, arch = process.arch) {
37
62
  if (platform !== "darwin" || arch !== "arm64")
@@ -71,6 +96,8 @@ export function edgeFailure(stderr, cmd) {
71
96
  const upgrade = cmd === "python3" ? "`pip install -U edge-tts`" : "`pipx upgrade edge-tts` (or `pip install -U edge-tts`)";
72
97
  if (/\b403\b|WSServerHandshakeError/.test(stderr))
73
98
  return `voiceover: Microsoft's voice service refused the request (403). That almost always means edge-tts is out of date — run ${upgrade}, then re-render. If it is already current, check that this computer's clock is right.`;
99
+ if (/Invalid voice/i.test(stderr))
100
+ return `voiceover: Edge does not know that voice. Use a name — ${Object.keys(VOICES).join(", ")} (heart, michael and aiden run on this Mac) — or an edge-tts id like en-US-AriaNeural. Local voices can also be written kokoro:<voice> or qwen:<speaker>.`;
74
101
  if (/getaddrinfo|ClientConnectorError|Cannot connect|Network is unreachable|TimeoutError/i.test(stderr))
75
102
  return "voiceover: could not reach Microsoft's voice service — is the network up? (edge-tts is a network service)";
76
103
  const last = stderr.trim().split("\n").filter(Boolean).pop() ?? "";
@@ -78,14 +105,28 @@ export function edgeFailure(stderr, cmd) {
78
105
  }
79
106
  function runEdge(e, args, timeout) {
80
107
  const local = e.cmd === "uvx";
108
+ // The first use of a local voice downloads its engine and model (~2.6 GB
109
+ // for Kokoro): on a slow line that is far past 15 minutes. Allow an hour
110
+ // then, keep the downloaders' progress bars out of the captured output
111
+ // (they overflowed it), and say which of the two went wrong.
112
+ const voice = args[args.indexOf("--voice") + 1] ?? "";
113
+ const firstUse = local && !localModelReady(voice);
81
114
  try {
82
- execFileSync(e.cmd, [...e.args, ...args], { stdio: "pipe", timeout: local ? Math.max(timeout, 900_000) : timeout, env: local ? { ...process.env, FFMPEG_BIN: ffmpegPath() } : process.env });
115
+ execFileSync(e.cmd, [...e.args, ...args], {
116
+ stdio: "pipe", maxBuffer: 64 * 1024 * 1024,
117
+ timeout: local ? (firstUse ? 3_600_000 : Math.max(timeout, 900_000)) : timeout,
118
+ env: local ? { ...process.env, FFMPEG_BIN: ffmpegPath(), HF_HUB_DISABLE_PROGRESS_BARS: "1", TQDM_DISABLE: "1" } : process.env,
119
+ });
83
120
  }
84
121
  catch (err) {
85
122
  const x = err;
86
123
  if (local) {
124
+ if (x.code === "ETIMEDOUT" || x.signal === "SIGTERM")
125
+ throw new Error(firstUse
126
+ ? "voiceover: the local voice's one-time setup (downloading its engine and model) did not finish within an hour — check the connection, then render again; what was downloaded is kept"
127
+ : "voiceover: the local voice took too long to speak — render again; if it keeps happening, use an Edge voice for now");
87
128
  const last = String(x.stderr ?? "").trim().split("\n").filter(Boolean).pop() ?? "";
88
- throw new Error(`voiceover: the local voice failed${last ? ` — ${last.slice(0, 240)}` : ""}`);
129
+ throw new Error(`voiceover: the local voice failed${last ? ` — ${last.slice(0, 240)}` : " without saying why"}${firstUse ? " (during its one-time setup)" : ""}`);
89
130
  }
90
131
  if (x.code === "ETIMEDOUT" || x.signal === "SIGTERM")
91
132
  throw new Error("voiceover: Microsoft's voice service did not answer in time — try again in a minute");
@@ -104,6 +145,7 @@ function ffmpegPath() {
104
145
  return "ffmpeg";
105
146
  }
106
147
  export function synthesize(text, voice, out, ratePercent = 0) {
148
+ voice = voiceId(voice);
107
149
  const cached = voiceCachePath(text, voice, ratePercent);
108
150
  if (fs.existsSync(cached) && fs.statSync(cached).size >= 200) {
109
151
  fs.copyFileSync(cached, out);
@@ -151,6 +193,7 @@ function parseSrt(srt) {
151
193
  * performance at those points. edge-tts writes the sentence timings itself.
152
194
  */
153
195
  export function synthesizeWithCues(text, voice, out, ratePercent = 0) {
196
+ voice = voiceId(voice);
154
197
  const cached = voiceCachePath(`cues\0${text}`, voice, ratePercent);
155
198
  const cachedSrt = cached.replace(/\.mp3$/, ".srt");
156
199
  if (fs.existsSync(cached) && fs.statSync(cached).size >= 200 && fs.existsSync(cachedSrt)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "retake-demos",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "Demo-as-code for coding agents: a YAML manifest in, a product walkthrough video out. Rerun the demo instead of re-recording it.",
5
5
  "keywords": [
6
6
  "demo",
@@ -52,7 +52,7 @@
52
52
  "retake": "tsx src/cli.ts",
53
53
  "ui": "tsx src/cli.ts ui",
54
54
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
55
- "test": "tsx --test test/*.test.ts",
55
+ "test": "tsx --test --test-concurrency=3 test/*.test.ts",
56
56
  "site:dev": "node scripts/serve-landing.mjs",
57
57
  "site:test": "node --test test/*-static.test.mjs",
58
58
  "site:test:browser": "node --test test/landing.test.mjs"
@@ -39,6 +39,37 @@ def pronounce(text: str) -> str:
39
39
  return "".join(out)
40
40
 
41
41
 
42
+ def short_espeak_data() -> None:
43
+ """espeak-ng keeps its data path in a fixed-size buffer: past ~150
44
+ characters it silently falls back to the path it was built with
45
+ (/Users/runner/work/…) and exits the process. uv's cache path plus
46
+ site-packages reaches that with a long username or a custom cache
47
+ folder. So espeak reads a copy of its data (~20 MB, made once) from a
48
+ short folder. A link would not do: phonemizer resolves it back to the
49
+ long path. Set after misaki has imported, which sets the long path."""
50
+ import shutil
51
+ import espeakng_loader
52
+ from phonemizer.backend.espeak.wrapper import EspeakWrapper
53
+ try:
54
+ import misaki.espeak # noqa: F401 (sets the long path; overridden below)
55
+ except Exception:
56
+ pass
57
+ data = espeakng_loader.get_data_path()
58
+ if len(data) > 100:
59
+ short = os.path.join(os.path.expanduser("~/Library/Caches/retake"), "espeak-ng-data")
60
+ marker = os.path.join(short, ".copied-from")
61
+ fresh = os.path.isfile(marker) and open(marker).read() == data
62
+ if not fresh:
63
+ if os.path.lexists(short):
64
+ shutil.rmtree(short) if os.path.isdir(short) and not os.path.islink(short) else os.remove(short)
65
+ shutil.copytree(data, short)
66
+ with open(marker, "w") as f:
67
+ f.write(data)
68
+ data = short
69
+ EspeakWrapper.set_library(espeakng_loader.get_library_path())
70
+ EspeakWrapper.set_data_path(data)
71
+
72
+
42
73
  EDGE_LUFS = -19.8 # measured: Edge's Andrew reading the same lines
43
74
  SR = 24000
44
75
  MODELS = {"kokoro": "mlx-community/Kokoro-82M-8bit", "qwen": "mlx-community/Qwen3-TTS-12Hz-0.6B-CustomVoice-8bit"}
@@ -57,6 +88,8 @@ def main() -> None:
57
88
  from mlx_audio.tts.utils import load_model
58
89
 
59
90
  engine, name = a.voice.split(":", 1)
91
+ if engine == "kokoro":
92
+ short_espeak_data()
60
93
  speed = 1 + int(re.sub(r"[%+]", "", a.rate) or 0) / 100
61
94
  model = load_model(MODELS[engine])
62
95
 
@@ -1,6 +1,19 @@
1
1
  {
2
2
  "_readme": "What's new, written for the person using Retake — not for whoever wrote it. Both the landing page and the Retake window read THIS file; there is no second copy. Rules for adding one: say what changed for them, name no files, no flags they will not type, no words like refactor or regression. If a line only makes sense to someone who has read the source, it does not belong here. Newest first. Add an entry on every release that changes what people see.",
3
3
  "releases": [
4
+ {
5
+ "version": "0.14.3",
6
+ "date": "2026-10-19",
7
+ "headline": "Choosing Kokoro or Aiden now works for someone starting from nothing",
8
+ "notes": [
9
+ "The voice you pick when you first open Retake is now remembered. Before, only the agent that made your first draft was told; the next one had to ask which voice you meant.",
10
+ "When your first draft is ready, the window shows the exact sentence to give your agent to add a voiceover, with a copy button.",
11
+ "Kokoro no longer fails to start when your Mac's user name or cache folder makes its files' path long.",
12
+ "Agents can name a voice the way they naturally would — heart, aiden, or Kokoro's own af_heart — and a voice name Retake does not know now says which names work.",
13
+ "The first use of a local voice downloads its engine as well as the voice: about 2.6 GB for Kokoro and 4 GB for Aiden. The window now says so, and allows up to an hour for it on a slow connection.",
14
+ "If an install was interrupted part-way through downloading ffmpeg, Retake now notices the broken copy and fetches it again instead of failing every render."
15
+ ]
16
+ },
4
17
  {
5
18
  "version": "0.14.2",
6
19
  "date": "2026-10-16",
package/skill/SKILL.md CHANGED
@@ -338,7 +338,7 @@ typed, chose and clicked, against the frames that came out.
338
338
  Nothing needs declaring. The inputs are already in the take — this is the half
339
339
  of the recording nothing has ever read.
340
340
 
341
- It found a real one on its first outing. An Avex quote entered two legs:
341
+ It found a real one on its first outing. A flight-booking demo's quote entered two legs:
342
342
 
343
343
  ```
344
344
  type "SGN" → #leg-from-0 type "SIN" → #leg-to-0
@@ -505,6 +505,12 @@ narrated too fast, the next too slow, and a third in a voice nobody picked.
505
505
  person who has heard it; the routine only says which voice, at what pace,
506
506
  once somebody has written one.
507
507
 
508
+ Voices are names: `andrew`, `ava`, `brian`, `emma`, `aria`, `ryan` (Edge,
509
+ online) and `heart`, `michael`, `aiden` (local: they run on the person's Mac
510
+ and the first narrated render downloads the model). The voice a person picked
511
+ when they first opened Retake is the routine's voice. If the routine says
512
+ `silent` and they want narration, ask which voice — never guess an id.
513
+
508
514
  If there is no routine, the advice below applies and nothing is locked.
509
515
 
510
516
  ## Style — how the person wants their videos to look