retake-demos 0.2.2 → 0.4.0
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/.env.example +10 -0
- package/AGENTS.md +277 -0
- package/README.md +63 -22
- package/dist/captions.js +55 -0
- package/dist/cards.js +177 -0
- package/dist/cli.js +300 -15
- package/dist/describe.js +30 -10
- package/dist/dryrun.js +185 -2
- package/dist/env.js +76 -0
- package/dist/manifest.js +178 -10
- package/dist/operator/run.js +4 -0
- package/dist/operator/tools.js +111 -13
- package/dist/pace.js +99 -0
- package/dist/presets.js +7 -7
- package/dist/record.js +475 -46
- package/dist/render.js +427 -64
- package/dist/tidy.js +103 -0
- package/dist/ui/chat.html +301 -43
- package/dist/ui/guide.html +2 -1
- package/dist/ui/server.js +172 -36
- package/dist/voice.js +36 -0
- package/llms.txt +11 -0
- package/package.json +5 -3
- package/site/releases.json +45 -0
- package/skill/SKILL.md +272 -8
- package/demos/example.yaml +0 -65
- package/dist/ui/index.html +0 -724
- package/site/index.html +0 -158
package/dist/ui/server.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* POST /api/describe { name, url, describe } → scout + model → draft manifest (saved)
|
|
11
11
|
* GET /api/provider { active, available }
|
|
12
12
|
* GET /api/presets [{name, ...}]
|
|
13
|
+
* GET /api/releases what's new, from site/releases.json
|
|
13
14
|
* GET/PUT/DELETE /api/drafts/:id an unfinished "new demo" (auto-saved as you go)
|
|
14
15
|
* DELETE /api/demos/:name remove a demo (manifest + outputs)
|
|
15
16
|
* PATCH /api/demos/:name/settings { preset?, layout?, camera?, cursor? } → edits keys, keeps comments
|
|
@@ -33,11 +34,12 @@ import { draftManifest, loadDotenv, pickProvider, proposeEdits, providerStatus,
|
|
|
33
34
|
import { applyEdits, receiptsFor } from "../edits.js";
|
|
34
35
|
import { dryRun } from "../dryrun.js";
|
|
35
36
|
import { startOperator, getSession, addPending, answerPending, markDone, stopSession } from "../operator/run.js";
|
|
36
|
-
import { gifskiBin, makeGif } from "../render.js";
|
|
37
|
-
import { captureHash } from "../record.js";
|
|
37
|
+
import { ffmpegBin, gifskiBin, makeGif } from "../render.js";
|
|
38
|
+
import { captureHash, restorePrevious } from "../record.js";
|
|
38
39
|
import { digest } from "../digest.js";
|
|
39
40
|
import { startOffer, startApp, listeningPorts } from "../appserver.js";
|
|
40
41
|
import { PKG_ROOT, PROJECT_ROOT, VERSION, entry } from "../paths.js";
|
|
42
|
+
import { SECRET_NAME, missingSecrets, writeEnvFile } from "../env.js";
|
|
41
43
|
const ROOT = PROJECT_ROOT;
|
|
42
44
|
const DEMOS = path.join(ROOT, "demos");
|
|
43
45
|
/** Where takes land. Default outputs/ in the project; changeable in Settings
|
|
@@ -117,7 +119,7 @@ const readBody = (req) => new Promise((ok) => {
|
|
|
117
119
|
req.on("end", () => ok(b));
|
|
118
120
|
});
|
|
119
121
|
const safeName = (s) => /^[a-z0-9-]+$/.test(s);
|
|
120
|
-
const MIME = { ".mp4": "video/mp4", ".gif": "image/gif", ".png": "image/png", ".md": "text/markdown", ".json": "application/json", ".html": "text/html", ".yaml": "text/yaml" };
|
|
122
|
+
const MIME = { ".mp4": "video/mp4", ".gif": "image/gif", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".md": "text/markdown", ".json": "application/json", ".html": "text/html", ".yaml": "text/yaml" };
|
|
121
123
|
/** A short name for "the app at this URL": the last path segment if there
|
|
122
124
|
is one (demo.playwright.dev/todomvc → todomvc), else the host without
|
|
123
125
|
www and TLD (www.saucedemo.com → saucedemo). localhost keeps its port —
|
|
@@ -162,6 +164,34 @@ function unassignDemoProject(name) {
|
|
|
162
164
|
delete map[name];
|
|
163
165
|
fs.writeFileSync(DEMO_PROJECTS, JSON.stringify(map, null, 2) + "\n");
|
|
164
166
|
}
|
|
167
|
+
/** Parsed manifests, keyed by path and invalidated by mtime+size.
|
|
168
|
+
Listing 32 demos meant 64 YAML parses and zod validations per request —
|
|
169
|
+
two seconds, on a call the window makes on load, on every agent event and
|
|
170
|
+
every 30s. Nothing about a file that has not changed can have changed. */
|
|
171
|
+
const manifestCache = new Map();
|
|
172
|
+
function cachedManifest(file) {
|
|
173
|
+
let key;
|
|
174
|
+
try {
|
|
175
|
+
const st = fs.statSync(file);
|
|
176
|
+
key = `${st.mtimeMs}:${st.size}`;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
manifestCache.delete(file);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const hit = manifestCache.get(file);
|
|
183
|
+
if (hit && hit.key === key)
|
|
184
|
+
return hit.manifest;
|
|
185
|
+
let manifest = null;
|
|
186
|
+
try {
|
|
187
|
+
manifest = loadManifest(file).manifest;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
manifest = null;
|
|
191
|
+
}
|
|
192
|
+
manifestCache.set(file, { key, manifest });
|
|
193
|
+
return manifest;
|
|
194
|
+
}
|
|
165
195
|
function listDemos(project) {
|
|
166
196
|
if (!fs.existsSync(DEMOS))
|
|
167
197
|
return [];
|
|
@@ -169,13 +199,12 @@ function listDemos(project) {
|
|
|
169
199
|
const assignments = demoProjects();
|
|
170
200
|
const byUrl = new Map();
|
|
171
201
|
for (const f of fs.readdirSync(DEMOS).filter((x) => /\.ya?ml$/.test(x))) {
|
|
172
|
-
|
|
173
|
-
|
|
202
|
+
const mm = cachedManifest(path.join(DEMOS, f));
|
|
203
|
+
if (mm) {
|
|
174
204
|
const asg = assignments[mm.name];
|
|
175
205
|
if (asg && mm.url && !byUrl.has(mm.url))
|
|
176
206
|
byUrl.set(mm.url, asg);
|
|
177
207
|
}
|
|
178
|
-
catch { /* invalid manifest: no vote */ }
|
|
179
208
|
}
|
|
180
209
|
return fs
|
|
181
210
|
.readdirSync(DEMOS)
|
|
@@ -184,7 +213,9 @@ function listDemos(project) {
|
|
|
184
213
|
const file = path.join(DEMOS, f);
|
|
185
214
|
let title = "", name = f.replace(/\.ya?ml$/, ""), valid = true, url = "", settings = {};
|
|
186
215
|
try {
|
|
187
|
-
const m =
|
|
216
|
+
const m = cachedManifest(file);
|
|
217
|
+
if (!m)
|
|
218
|
+
throw new Error("invalid");
|
|
188
219
|
title = m.title ?? "";
|
|
189
220
|
name = m.name;
|
|
190
221
|
url = m.url;
|
|
@@ -205,6 +236,11 @@ function listDemos(project) {
|
|
|
205
236
|
// Capture lands in a second "localhost:3100" fold next to "capture".
|
|
206
237
|
const assigned = assignments[name] ?? (url ? byUrl.get(url) : undefined);
|
|
207
238
|
const group = assigned ? path.basename(assigned) : shortGroup(url);
|
|
239
|
+
// Heal a folder whose run was killed mid-recording: the previous take is
|
|
240
|
+
// in its stash and nothing has put it back yet. The window must never
|
|
241
|
+
// show someone an empty demo when their recording is safe on disk.
|
|
242
|
+
if (!fs.existsSync(path.join(outRoot(), name, "take.json")))
|
|
243
|
+
restorePrevious(path.join(outRoot(), name));
|
|
208
244
|
const take = path.join(outRoot(), name, "take.json");
|
|
209
245
|
let lastTake = null;
|
|
210
246
|
// Does the browser need to run again, or is a re-render enough? The
|
|
@@ -326,25 +362,7 @@ function startRun(name, mode = {}) {
|
|
|
326
362
|
return run;
|
|
327
363
|
}
|
|
328
364
|
/** Read/write the .env in the project root — only the keys we own. */
|
|
329
|
-
function writeEnv(set) {
|
|
330
|
-
const file = path.join(ROOT, ".env");
|
|
331
|
-
const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").split("\n") : [];
|
|
332
|
-
for (const [k, v] of Object.entries(set)) {
|
|
333
|
-
if (v === undefined)
|
|
334
|
-
continue;
|
|
335
|
-
const i = lines.findIndex((l) => l.startsWith(k + "="));
|
|
336
|
-
const line = `${k}=${v}`;
|
|
337
|
-
if (v === "") {
|
|
338
|
-
if (i >= 0)
|
|
339
|
-
lines.splice(i, 1);
|
|
340
|
-
}
|
|
341
|
-
else if (i >= 0)
|
|
342
|
-
lines[i] = line;
|
|
343
|
-
else
|
|
344
|
-
lines.push(line);
|
|
345
|
-
}
|
|
346
|
-
fs.writeFileSync(file, lines.filter((l, i, a) => l !== "" || i < a.length - 1).join("\n").replace(/\n*$/, "\n"));
|
|
347
|
-
}
|
|
365
|
+
function writeEnv(set) { writeEnvFile(ROOT, set); }
|
|
348
366
|
function envSummary() {
|
|
349
367
|
return { localUrl: process.env.RETAKE_LOCAL_URL ?? "", localModel: process.env.RETAKE_LOCAL_MODEL ?? "", groq: !!process.env.GROQ_API_KEY, mistral: !!process.env.MISTRAL_API_KEY };
|
|
350
368
|
}
|
|
@@ -405,6 +423,8 @@ function withStaleness(a) {
|
|
|
405
423
|
return { ...a, active: false, stale: true };
|
|
406
424
|
return a;
|
|
407
425
|
}
|
|
426
|
+
const secretRequests = new Map();
|
|
427
|
+
const openSecretRequests = () => [...secretRequests.values()].filter((r) => !r.filled).map(({ id, names, why }) => ({ id, names, why }));
|
|
408
428
|
function noteActivity(b) {
|
|
409
429
|
if (!activity.active && !b.done) {
|
|
410
430
|
activity.active = true;
|
|
@@ -430,7 +450,6 @@ function noteActivity(b) {
|
|
|
430
450
|
}
|
|
431
451
|
export function serve(port) {
|
|
432
452
|
loadDotenv(ROOT);
|
|
433
|
-
const page = fs.readFileSync(path.join(HERE, "index.html"), "utf8");
|
|
434
453
|
const chatPage = fs.readFileSync(path.join(HERE, "chat.html"), "utf8");
|
|
435
454
|
const guidePage = fs.readFileSync(path.join(HERE, "guide.html"), "utf8");
|
|
436
455
|
const server = http.createServer(async (req, res) => {
|
|
@@ -448,9 +467,21 @@ export function serve(port) {
|
|
|
448
467
|
res.writeHead(200, { "content-type": "text/html" });
|
|
449
468
|
return res.end(fs.readFileSync(f));
|
|
450
469
|
}
|
|
451
|
-
|
|
470
|
+
// Both prefixes: the page is served at /landing here and from a site
|
|
471
|
+
// root when deployed, so its asset paths are relative either way.
|
|
472
|
+
// releases.json sits beside index.html when deployed, so the preview
|
|
473
|
+
// has to serve it from the same two prefixes the page might be under.
|
|
474
|
+
const mrel = /^(?:\/landing)?\/releases\.json$/.exec(p);
|
|
475
|
+
if (mrel && (req.method === "GET" || req.method === "HEAD")) {
|
|
476
|
+
const f = path.join(PKG_ROOT, "site", "releases.json");
|
|
477
|
+
if (!fs.existsSync(f))
|
|
478
|
+
return json(res, 404, { error: "not found" });
|
|
479
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
480
|
+
return res.end(req.method === "HEAD" ? undefined : fs.readFileSync(f));
|
|
481
|
+
}
|
|
482
|
+
const msite = /^(?:\/landing)?\/(media|logos)\/([A-Za-z0-9._-]+)$/.exec(p);
|
|
452
483
|
if (msite && (req.method === "GET" || req.method === "HEAD")) {
|
|
453
|
-
const f = path.join(PKG_ROOT, "site",
|
|
484
|
+
const f = path.join(PKG_ROOT, "site", msite[1], msite[2]);
|
|
454
485
|
if (!fs.existsSync(f))
|
|
455
486
|
return json(res, 404, { error: "not found" });
|
|
456
487
|
res.writeHead(200, { "content-type": MIME[path.extname(f)] ?? "application/octet-stream", "content-length": fs.statSync(f).size });
|
|
@@ -462,10 +493,6 @@ export function serve(port) {
|
|
|
462
493
|
res.writeHead(200, { "content-type": "text/html" });
|
|
463
494
|
return res.end(guidePage);
|
|
464
495
|
}
|
|
465
|
-
if (p === "/classic" && req.method === "GET") {
|
|
466
|
-
res.writeHead(200, { "content-type": "text/html" });
|
|
467
|
-
return res.end(page);
|
|
468
|
-
}
|
|
469
496
|
if (p === "/manifest.webmanifest") {
|
|
470
497
|
res.writeHead(200, { "content-type": "application/manifest+json" });
|
|
471
498
|
return res.end(JSON.stringify({ name: "Retake", short_name: "Retake", description: "Rerun the demo, don't re-record it.", start_url: "/", scope: "/", display: "standalone", background_color: "#edefe8", theme_color: "#edefe8", icons: [{ src: "/icon-192.png", sizes: "192x192", type: "image/png" }, { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" }] }));
|
|
@@ -507,6 +534,19 @@ export function serve(port) {
|
|
|
507
534
|
}
|
|
508
535
|
if (p === "/api/provider" && req.method === "GET")
|
|
509
536
|
return json(res, 200, providerStatus());
|
|
537
|
+
if (p === "/api/releases" && req.method === "GET") {
|
|
538
|
+
// The same site/releases.json the landing page reads — one list, so
|
|
539
|
+
// the window and the page can never disagree about what shipped.
|
|
540
|
+
const f = path.join(PKG_ROOT, "site", "releases.json");
|
|
541
|
+
if (!fs.existsSync(f))
|
|
542
|
+
return json(res, 200, { releases: [] });
|
|
543
|
+
try {
|
|
544
|
+
return json(res, 200, JSON.parse(fs.readFileSync(f, "utf8")));
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return json(res, 200, { releases: [] });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
510
550
|
if (p === "/api/presets" && req.method === "GET")
|
|
511
551
|
return json(res, 200, Object.values(PRESETS));
|
|
512
552
|
if (p === "/api/drafts" && req.method === "GET")
|
|
@@ -536,6 +576,61 @@ export function serve(port) {
|
|
|
536
576
|
const seen = new Set();
|
|
537
577
|
return json(res, 200, items.filter((x) => !seen.has(x.name) && seen.add(x.name)));
|
|
538
578
|
}
|
|
579
|
+
// ---------- the poster: any frame, a card, or their own file ----------
|
|
580
|
+
// thumbnail.png IS the poster. Every source here just writes it, so the
|
|
581
|
+
// choice costs no re-render and never touches the video.
|
|
582
|
+
const mcov = /^\/api\/cover\/([a-z0-9-]+)$/.exec(p);
|
|
583
|
+
if (mcov && req.method === "POST") {
|
|
584
|
+
const name = mcov[1];
|
|
585
|
+
const dir = path.join(outRoot(), name);
|
|
586
|
+
const target = path.join(dir, "thumbnail.png");
|
|
587
|
+
if (!fs.existsSync(dir))
|
|
588
|
+
return json(res, 404, { error: `no take for ${name}` });
|
|
589
|
+
const b = JSON.parse(await readBody(req));
|
|
590
|
+
if (b.auto) {
|
|
591
|
+
fs.rmSync(path.join(dir, ".poster"), { force: true });
|
|
592
|
+
return json(res, 200, { ok: true, poster: "auto — the next render takes it from the thumbnail scene" });
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
if (b.still) {
|
|
596
|
+
// A scene still, by its file name under stills/.
|
|
597
|
+
const src = path.join(dir, "stills", path.basename(b.still));
|
|
598
|
+
if (!fs.existsSync(src))
|
|
599
|
+
return json(res, 404, { error: `no still ${b.still}` });
|
|
600
|
+
fs.copyFileSync(src, target);
|
|
601
|
+
}
|
|
602
|
+
else if (b.file) {
|
|
603
|
+
// A candidate already in the take (cover.png, cover-titled.png).
|
|
604
|
+
const src = path.join(dir, path.basename(b.file));
|
|
605
|
+
if (!fs.existsSync(src))
|
|
606
|
+
return json(res, 404, { error: `no file ${b.file}` });
|
|
607
|
+
fs.copyFileSync(src, target);
|
|
608
|
+
}
|
|
609
|
+
else if (typeof b.at === "number") {
|
|
610
|
+
// Any moment of the finished video — usually the best frame is
|
|
611
|
+
// nowhere near a scene marker.
|
|
612
|
+
const mp4 = path.join(dir, "demo.mp4");
|
|
613
|
+
if (!fs.existsSync(mp4))
|
|
614
|
+
return json(res, 404, { error: "no demo.mp4" });
|
|
615
|
+
execFileSync(ffmpegBin(), ["-y", "-loglevel", "error", "-ss", Math.max(0, b.at).toFixed(2), "-i", mp4, "-frames:v", "1", target]);
|
|
616
|
+
}
|
|
617
|
+
else if (b.dataUrl) {
|
|
618
|
+
// Their own image.
|
|
619
|
+
const m2 = /^data:image\/(png|jpe?g|webp);base64,(.+)$/.exec(b.dataUrl);
|
|
620
|
+
if (!m2)
|
|
621
|
+
return json(res, 400, { error: "expected a png, jpeg or webp data URL" });
|
|
622
|
+
fs.writeFileSync(target, Buffer.from(m2[2], "base64"));
|
|
623
|
+
}
|
|
624
|
+
else
|
|
625
|
+
return json(res, 400, { error: "give a still, a file, an `at` time, or a dataUrl" });
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
return json(res, 500, { error: e.message });
|
|
629
|
+
}
|
|
630
|
+
// The choice survives re-renders; "auto" hands it back to the scene default.
|
|
631
|
+
fs.writeFileSync(path.join(dir, ".poster"), JSON.stringify({ ...b, dataUrl: b.dataUrl ? "(uploaded)" : undefined, at: b.at }));
|
|
632
|
+
return json(res, 200, { ok: true, poster: "thumbnail.png" });
|
|
633
|
+
}
|
|
539
634
|
if (p === "/api/trash/clear" && req.method === "POST") {
|
|
540
635
|
const trash = path.join(ROOT, ".trash");
|
|
541
636
|
let n = 0;
|
|
@@ -720,9 +815,50 @@ export function serve(port) {
|
|
|
720
815
|
}
|
|
721
816
|
if (p === "/api/activity" && req.method === "GET")
|
|
722
817
|
return json(res, 200, withStaleness(activity));
|
|
818
|
+
// ---------- secrets: asked for by name, typed into the window, written here ----------
|
|
819
|
+
if (p === "/api/secrets/request" && req.method === "POST") {
|
|
820
|
+
const b = JSON.parse(await readBody(req));
|
|
821
|
+
const names = (b.names ?? []).filter((n) => SECRET_NAME.test(n));
|
|
822
|
+
if (!names.length)
|
|
823
|
+
return json(res, 400, { error: "names must be like APP_PASSWORD" });
|
|
824
|
+
// The same ask twice is one form, not two stacked ones.
|
|
825
|
+
const dup = [...secretRequests.values()].find((x) => !x.filled && x.names.join() === names.join());
|
|
826
|
+
if (dup)
|
|
827
|
+
return json(res, 200, { id: dup.id });
|
|
828
|
+
const r = { id: "s" + Date.now().toString(36), names, why: String(b.why ?? "").slice(0, 300), filled: false, at: Date.now() };
|
|
829
|
+
secretRequests.set(r.id, r);
|
|
830
|
+
for (const w of activityWatchers)
|
|
831
|
+
w({ type: "secrets", data: { id: r.id, names: r.names, why: r.why } });
|
|
832
|
+
return json(res, 200, { id: r.id });
|
|
833
|
+
}
|
|
834
|
+
if (p === "/api/secrets/pending" && req.method === "GET")
|
|
835
|
+
return json(res, 200, openSecretRequests());
|
|
836
|
+
if (p === "/api/secrets/fill" && req.method === "POST") {
|
|
837
|
+
const b = JSON.parse(await readBody(req));
|
|
838
|
+
const r = secretRequests.get(b.id);
|
|
839
|
+
if (!r)
|
|
840
|
+
return json(res, 404, { error: "no such request" });
|
|
841
|
+
const values = {};
|
|
842
|
+
for (const [k, v] of Object.entries(b.values ?? {}))
|
|
843
|
+
if (r.names.includes(k) && typeof v === "string" && v.trim())
|
|
844
|
+
values[k] = v;
|
|
845
|
+
if (!Object.keys(values).length)
|
|
846
|
+
return json(res, 400, { error: "nothing to save" });
|
|
847
|
+
writeEnvFile(ROOT, values);
|
|
848
|
+
if (!missingSecrets(ROOT, r.names).length)
|
|
849
|
+
r.filled = true;
|
|
850
|
+
for (const w of activityWatchers)
|
|
851
|
+
w({ type: "secrets-done", data: { id: r.id, filled: r.filled } });
|
|
852
|
+
return json(res, 200, { ok: true, filled: r.filled, set: Object.keys(values) });
|
|
853
|
+
}
|
|
854
|
+
const msec = /^\/api\/secrets\/(s[a-z0-9]+)$/.exec(p);
|
|
855
|
+
if (msec && req.method === "GET") {
|
|
856
|
+
const r = secretRequests.get(msec[1]);
|
|
857
|
+
return r ? json(res, 200, { filled: r.filled, missing: missingSecrets(ROOT, r.names) }) : json(res, 404, { error: "no such request" });
|
|
858
|
+
}
|
|
723
859
|
if (p === "/api/activity/stream" && req.method === "GET") {
|
|
724
860
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
725
|
-
res.write(`event: state\ndata: ${JSON.stringify({ ...activity, boot: BOOT })}\n\n`);
|
|
861
|
+
res.write(`event: state\ndata: ${JSON.stringify({ ...activity, boot: BOOT, secrets: openSecretRequests() })}\n\n`);
|
|
726
862
|
const send = (ev) => res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`);
|
|
727
863
|
activityWatchers.add(send);
|
|
728
864
|
const beat = setInterval(() => res.write(": ping\n\n"), 25_000);
|
|
@@ -859,7 +995,7 @@ export function serve(port) {
|
|
|
859
995
|
return json(res, 200, { provider, selected, models: [{ id: selected, name: selected || "Configured default" }] });
|
|
860
996
|
}
|
|
861
997
|
if (p === "/api/settings" && req.method === "GET")
|
|
862
|
-
return json(res, 200, { ...providerStatus(), model: process.env.RETAKE_MODEL ?? "", selection: modelSelection(), gifski: !!gifskiBin(), env: envSummary(), outDir: outRoot(), outDefault: OUT_DEFAULT, version: VERSION });
|
|
998
|
+
return json(res, 200, { envPath: path.join(ROOT, ".env"), ...providerStatus(), model: process.env.RETAKE_MODEL ?? "", selection: modelSelection(), gifski: !!gifskiBin(), env: envSummary(), outDir: outRoot(), outDefault: OUT_DEFAULT, version: VERSION });
|
|
863
999
|
const mopen = /^\/api\/open\/([a-z0-9-]+)$/.exec(p);
|
|
864
1000
|
if (mopen && req.method === "POST") {
|
|
865
1001
|
const dir = path.join(outRoot(), mopen[1]);
|
|
@@ -1023,7 +1159,7 @@ export function serve(port) {
|
|
|
1023
1159
|
if (b.camera === "static")
|
|
1024
1160
|
node.set("camera", "static");
|
|
1025
1161
|
else if (b.camera === "auto")
|
|
1026
|
-
node.
|
|
1162
|
+
node.set("camera", "auto");
|
|
1027
1163
|
else if (typeof b.camera === "object")
|
|
1028
1164
|
node.set("camera", b.camera);
|
|
1029
1165
|
}
|
package/dist/voice.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voiceover: the captions read aloud. The captions ARE the script — each
|
|
3
|
+
* scene's line is synthesized once and placed at the scene's start, so the
|
|
4
|
+
* narration is honest to the take by construction.
|
|
5
|
+
*
|
|
6
|
+
* Engine: edge-tts (Microsoft's neural voices — free, network). Found as a
|
|
7
|
+
* CLI (`edge-tts`, pipx/pip) or module (`python3 -m edge_tts`). Retake does
|
|
8
|
+
* not bundle a voice engine; the error says how to install one.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
13
|
+
export const DEFAULT_VOICE = "en-US-JennyNeural";
|
|
14
|
+
function engine() {
|
|
15
|
+
const home = process.env.HOME ?? "";
|
|
16
|
+
for (const c of ["edge-tts", path.join(home, ".local", "bin", "edge-tts")]) {
|
|
17
|
+
if (spawnSync(c, ["--help"], { stdio: "ignore" }).status === 0)
|
|
18
|
+
return { cmd: c, args: [] };
|
|
19
|
+
}
|
|
20
|
+
if (spawnSync("python3", ["-m", "edge_tts", "--help"], { stdio: "ignore" }).status === 0)
|
|
21
|
+
return { cmd: "python3", args: ["-m", "edge_tts"] };
|
|
22
|
+
throw new Error("voiceover needs edge-tts — install it once with `pipx install edge-tts` (or `pip install edge-tts`), then re-render");
|
|
23
|
+
}
|
|
24
|
+
export function synthesize(text, voice, out) {
|
|
25
|
+
const e = engine();
|
|
26
|
+
execFileSync(e.cmd, [...e.args, "--voice", voice, "--text", text, "--write-media", out], { stdio: "pipe", timeout: 60_000 });
|
|
27
|
+
if (!fs.existsSync(out) || fs.statSync(out).size < 200)
|
|
28
|
+
throw new Error(`voiceover: nothing came back for “${text.slice(0, 40)}…” — is the network up? (edge-tts is a network service)`);
|
|
29
|
+
}
|
|
30
|
+
export function audioSeconds(ffmpeg, file) {
|
|
31
|
+
const out = String(spawnSync(ffmpeg, ["-hide_banner", "-i", file], { encoding: "utf8" }).stderr);
|
|
32
|
+
const m = /Duration:\s*(\d+):(\d+):(\d+\.?\d*)/.exec(out);
|
|
33
|
+
if (!m)
|
|
34
|
+
throw new Error(`could not read duration of ${file}`);
|
|
35
|
+
return Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
|
|
36
|
+
}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Retake
|
|
2
|
+
|
|
3
|
+
> Demo-as-code for coding agents: a YAML manifest in, a product walkthrough video out. Rerun the demo instead of re-recording it.
|
|
4
|
+
|
|
5
|
+
An agent that was handed this repository should read README.md first — its "If you are a coding agent" section is the setup sequence — then AGENTS.md (the CLI loop, manifests, logins, what trips agents up) and skill/SKILL.md (the method Claude Code loads as a skill).
|
|
6
|
+
|
|
7
|
+
- README.md: https://raw.githubusercontent.com/glebbogachev00/retake/main/README.md
|
|
8
|
+
- AGENTS.md: https://raw.githubusercontent.com/glebbogachev00/retake/main/AGENTS.md
|
|
9
|
+
- skill/SKILL.md: https://raw.githubusercontent.com/glebbogachev00/retake/main/skill/SKILL.md
|
|
10
|
+
- demos/example.yaml: https://raw.githubusercontent.com/glebbogachev00/retake/main/demos/example.yaml
|
|
11
|
+
- npm: https://www.npmjs.com/package/retake-demos
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "retake-demos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
32
|
"skill",
|
|
33
|
-
"site/index.html",
|
|
34
33
|
"assets/logo.svg",
|
|
35
34
|
"assets/icon-192.png",
|
|
36
35
|
"assets/icon-512.png",
|
|
@@ -38,7 +37,10 @@
|
|
|
38
37
|
".env.example",
|
|
39
38
|
"README.md",
|
|
40
39
|
"LICENSE",
|
|
41
|
-
"scripts/patch-testreel.mjs"
|
|
40
|
+
"scripts/patch-testreel.mjs",
|
|
41
|
+
"AGENTS.md",
|
|
42
|
+
"llms.txt",
|
|
43
|
+
"site/releases.json"
|
|
42
44
|
],
|
|
43
45
|
"scripts": {
|
|
44
46
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/ui && cp src/ui/*.html dist/ui/ && chmod +x dist/cli.js",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
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
|
+
"releases": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.4.0",
|
|
6
|
+
"date": "2026-08-25",
|
|
7
|
+
"headline": "Videos come out the size you asked for — and changing your mind is nearly free",
|
|
8
|
+
"notes": [
|
|
9
|
+
"Every video is exactly the size of the format you picked. Landscape is 1920×1080, square is square, vertical fits a phone. No more sizes drifting between takes.",
|
|
10
|
+
"Changing your mind costs seconds, not another recording. Size, shape, format, captions, speed, zoom, music, title cards and the cover image all re-make themselves from the recording you already have.",
|
|
11
|
+
"Moved a caption to the wrong moment? Nudge it. The caption, the still and the cover all follow, and you never record again.",
|
|
12
|
+
"Only the ending changed? Record only the ending. The earlier steps still run so your app gets to the right place, but they happen at full speed and off camera. On a seven-minute demo that is about three minutes instead of sixteen.",
|
|
13
|
+
"Your old recordings are kept. A re-record no longer throws away the take you had — you can look at the previous ones and go back to any of them. Only the recording itself is stored, so it stays small.",
|
|
14
|
+
"A failed recording can no longer cost you the one that worked.",
|
|
15
|
+
"New: clear out old files safely. It shows you what it would free before removing anything, and never touches a recording.",
|
|
16
|
+
"In the window: pick several demos at once — or a whole folder — and archive them together. The archive empties in one click.",
|
|
17
|
+
"Launch videos can open with a title card and close with your own sign-off, put music underneath, and add a spoken voiceover.",
|
|
18
|
+
"Dead air gets trimmed: while your app is thinking, the video moves through the wait instead of sitting in it.",
|
|
19
|
+
"You can sign in to a demo account without any AI ever seeing your password.",
|
|
20
|
+
"Renders are about twice as fast.",
|
|
21
|
+
"Your agent now checks far more before recording — that a click will land on something clickable, that a stub is really being used, that it is looking at the same screen the recording will — so fewer takes are wasted finding out mid-recording.",
|
|
22
|
+
"On a long demo your agent shows you a quick draft and asks before spending the full recording.",
|
|
23
|
+
"The window has been rebuilt: one place for everything, and it stops shouting at you."
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.3.0",
|
|
28
|
+
"date": "2026-08-23",
|
|
29
|
+
"headline": "Demos behind a login",
|
|
30
|
+
"notes": [
|
|
31
|
+
"Demos of apps that need signing in. You sign in once in a real browser window; nothing types your password and nothing records it.",
|
|
32
|
+
"The video player shows your video in its own shape, with no black bars around it."
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"version": "0.2.0",
|
|
37
|
+
"date": "2026-08-23",
|
|
38
|
+
"headline": "The first public release",
|
|
39
|
+
"notes": [
|
|
40
|
+
"Describe a demo in one readable file, and get a video, still frames, and a written record of what happened.",
|
|
41
|
+
"When your app changes, fix the file and run it again — instead of recording the whole thing over."
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
}
|