retake-demos 0.2.2 → 0.3.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 +213 -0
- package/README.md +58 -20
- package/demos/example.yaml +1 -1
- package/dist/captions.js +56 -0
- package/dist/cards.js +134 -0
- package/dist/cli.js +114 -6
- package/dist/describe.js +28 -9
- package/dist/dryrun.js +3 -0
- package/dist/env.js +76 -0
- package/dist/manifest.js +111 -6
- package/dist/operator/run.js +4 -0
- package/dist/operator/tools.js +62 -5
- package/dist/pace.js +99 -0
- package/dist/presets.js +12 -12
- package/dist/record.js +36 -4
- package/dist/render.js +179 -48
- package/dist/ui/chat.html +82 -3
- package/dist/ui/guide.html +2 -1
- package/dist/ui/index.html +2 -2
- package/dist/ui/server.js +109 -26
- package/dist/voice.js +36 -0
- package/llms.txt +11 -0
- package/package.json +6 -3
- package/site/index.html +141 -39
- package/site/logos/avexjets.png +0 -0
- package/skill/SKILL.md +124 -6
package/dist/ui/chat.html
CHANGED
|
@@ -56,7 +56,9 @@
|
|
|
56
56
|
/* the video */
|
|
57
57
|
.pin { display:none; }
|
|
58
58
|
.pin.show { display:block; }
|
|
59
|
-
|
|
59
|
+
/* The element takes the video's own shape: width follows the height cap, so
|
|
60
|
+
a 1.6:1 take gets no black bars at the sides. */
|
|
61
|
+
.pin video { width:auto; max-width:100%; height:auto; max-height:62vh; margin:0 auto; border-radius:12px; background:#000; display:block; border:1px solid var(--rule2); box-shadow:0 10px 30px rgba(24,28,24,.10); }
|
|
60
62
|
.under { display:flex; align-items:center; gap:8px; margin-top:10px; font-size:13px; color:var(--muted); flex-wrap:wrap; }
|
|
61
63
|
.under a, .under button { color:var(--ink); text-decoration:none; border:1px solid var(--rule2); background:#fff; border-radius:999px; padding:5px 12px; cursor:pointer; font-size:13px; }
|
|
62
64
|
.under a:hover, .under button:hover { border-color:var(--ink); }
|
|
@@ -68,6 +70,13 @@
|
|
|
68
70
|
.stills { display:flex; gap:8px; margin-top:10px; overflow-x:auto; padding-bottom:4px; }
|
|
69
71
|
.stills img { height:64px; border-radius:7px; border:1px solid var(--rule2); box-shadow:0 2px 6px rgba(24,28,24,.06); cursor:pointer; flex:none; }
|
|
70
72
|
.stills img:hover { border-color:var(--ink); }
|
|
73
|
+
.stills img.poster, .coverrow img.poster { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent); }
|
|
74
|
+
.coverrow { display:flex; gap:8px; align-items:center; margin-top:10px; flex-wrap:wrap; }
|
|
75
|
+
.coverrow img { height:64px; border-radius:7px; border:1px solid var(--rule2); cursor:pointer; }
|
|
76
|
+
.coverrow img:hover { border-color:var(--ink); }
|
|
77
|
+
.coverrow .lab { font:11px ui-monospace, Menlo, monospace; letter-spacing:.06em; text-transform:uppercase; color:var(--muted); }
|
|
78
|
+
.coverrow label.up { font-size:12.5px; color:var(--muted); border:1px dashed var(--rule2); border-radius:8px; padding:8px 12px; cursor:pointer; }
|
|
79
|
+
.coverrow label.up:hover { border-color:var(--ink); color:var(--ink); }
|
|
71
80
|
/* the library: one quiet card at the video's side */
|
|
72
81
|
.lib { border:1px solid var(--rule); background:var(--card); border-radius:16px; padding:14px 10px; position:sticky; top:14px; line-height:1.4; }
|
|
73
82
|
.lib h2 { font:11px ui-monospace, Menlo, monospace; letter-spacing:.07em; text-transform:uppercase; color:var(--muted); font-weight:500; margin:2px 0 10px 12px; }
|
|
@@ -141,8 +150,10 @@
|
|
|
141
150
|
</select>
|
|
142
151
|
<a id="vidDl" download>Download</a>
|
|
143
152
|
<button id="vidFolder">Show in folder</button>
|
|
153
|
+
<button id="vidCover" title="Make the frame showing right now the poster">Use this frame</button>
|
|
144
154
|
<!-- The workshop (/classic) still exists but is not advertised until it has been tested as carefully as this page. -->
|
|
145
155
|
</div>
|
|
156
|
+
<div class="coverrow" id="coverrow"></div>
|
|
146
157
|
<div class="stills" id="stills"></div>
|
|
147
158
|
</div>
|
|
148
159
|
<div class="empty" id="empty" style="display:none">
|
|
@@ -164,6 +175,19 @@ let current = null, demos = [], settings = null;
|
|
|
164
175
|
async function api(u, o) { const r = await fetch(u, o); const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || r.statusText); return j; }
|
|
165
176
|
|
|
166
177
|
const REPO = 'https://github.com/glebbogachev00/retake';
|
|
178
|
+
async function setCover(body, marked) {
|
|
179
|
+
try {
|
|
180
|
+
await api('/api/cover/' + current, { method: 'POST', body: JSON.stringify(body) });
|
|
181
|
+
// Cache-bust: the poster keeps its filename, so the browser needs telling.
|
|
182
|
+
const v = '?v=' + Date.now();
|
|
183
|
+
$('vid').poster = `/out/${current}/thumbnail.png` + v;
|
|
184
|
+
for (const i of document.querySelectorAll('.stills img, .coverrow img')) i.classList.remove('poster');
|
|
185
|
+
if (marked) marked.classList.add('poster');
|
|
186
|
+
const meta = $('vidMeta').textContent;
|
|
187
|
+
$('vidMeta').textContent = meta.replace(/ · poster set$/, '') + ' · poster set';
|
|
188
|
+
} catch (e) { $('vidMeta').textContent = 'could not set the poster: ' + e.message; }
|
|
189
|
+
}
|
|
190
|
+
|
|
167
191
|
document.getElementById('gremlinBtn').onclick = async () => {
|
|
168
192
|
const veil = document.createElement('div');
|
|
169
193
|
veil.className = 'veil';
|
|
@@ -246,12 +270,30 @@ async function pin(name) {
|
|
|
246
270
|
$('vidMeta').title = failed?.screen ? 'On screen at that moment: ' + failed.screen : '';
|
|
247
271
|
$('vidDl').href = `/out/${name}/demo.mp4${q}`; $('vidDl').setAttribute('download', name + '.mp4');
|
|
248
272
|
$('vidFolder').onclick = () => api('/api/open/' + name, { method: 'POST' }).catch(() => {});
|
|
273
|
+
$('vidCover').onclick = () => setCover({ at: $('vid').currentTime });
|
|
249
274
|
const tempo = (demos.find((x) => x.name === name)?.settings?.tempo) || 1;
|
|
250
275
|
$('vidSpeed').value = String(tempo);
|
|
251
276
|
$('vid').playbackRate = 1; // the file already carries its tempo
|
|
277
|
+
// The poster is the first thing anyone sees of a video, so it is chosen
|
|
278
|
+
// here, not guessed: any scene still, any frame of the take, a generated
|
|
279
|
+
// cover, or the person's own image. All of it just rewrites thumbnail.png,
|
|
280
|
+
// so it costs no re-render.
|
|
252
281
|
const st = (d.files || []).filter((f) => f.startsWith('stills/')).sort();
|
|
253
|
-
$('stills').innerHTML = st.map((f) => `<img src="/out/${name}/${f}${q}" title="${esc(f.slice(7, -4))}" loading="lazy">`).join('');
|
|
254
|
-
for (const img of $('stills').querySelectorAll('img')) img.onclick = () =>
|
|
282
|
+
$('stills').innerHTML = st.map((f) => `<img src="/out/${name}/${f}${q}" title="Make this the poster — ${esc(f.slice(7, -4))}" loading="lazy" data-still="${esc(f.slice(7))}">`).join('');
|
|
283
|
+
for (const img of $('stills').querySelectorAll('img')) img.onclick = () => setCover({ still: img.dataset.still }, img);
|
|
284
|
+
|
|
285
|
+
const covers = (d.files || []).filter((f) => f === 'cover.png' || f === 'cover-titled.png');
|
|
286
|
+
$('coverrow').innerHTML =
|
|
287
|
+
(covers.length ? `<span class="lab">poster</span>` + covers.map((f) => `<img src="/out/${name}/${f}${q}" title="${f === 'cover.png' ? 'The title card' : 'This frame, with the title on it'}" data-file="${f}">`).join('') : `<span class="lab">poster</span>`) +
|
|
288
|
+
`<label class="up">Upload an image<input type="file" accept="image/png,image/jpeg,image/webp" hidden id="coverUp"></label>`;
|
|
289
|
+
for (const img of $('coverrow').querySelectorAll('img[data-file]')) img.onclick = () => setCover({ file: img.dataset.file }, img);
|
|
290
|
+
$('coverUp').onchange = (e) => {
|
|
291
|
+
const f = e.target.files?.[0];
|
|
292
|
+
if (!f) return;
|
|
293
|
+
const r = new FileReader();
|
|
294
|
+
r.onload = () => setCover({ dataUrl: r.result });
|
|
295
|
+
r.readAsDataURL(f);
|
|
296
|
+
};
|
|
255
297
|
$('pin').classList.add('show');
|
|
256
298
|
drawDemos();
|
|
257
299
|
return true;
|
|
@@ -355,6 +397,41 @@ $('vidSpeed').addEventListener('change', async () => {
|
|
|
355
397
|
} catch (e) { $('vidMeta').textContent = `${current} · could not re-render: ${e.message}`; }
|
|
356
398
|
});
|
|
357
399
|
|
|
400
|
+
// ---- secrets: the agent asks by name; the value goes to .env, never to it ----
|
|
401
|
+
const secretOpen = new Set();
|
|
402
|
+
function askSecrets(r) {
|
|
403
|
+
if (!r || secretOpen.has(r.id)) return;
|
|
404
|
+
secretOpen.add(r.id);
|
|
405
|
+
const isSecret = (n) => /PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n);
|
|
406
|
+
const veil = document.createElement('div');
|
|
407
|
+
veil.className = 'veil';
|
|
408
|
+
veil.innerHTML = `<div class="modal" role="dialog" aria-modal="true" style="width:min(92vw,460px)">
|
|
409
|
+
<h3>Your agent needs a sign-in</h3>
|
|
410
|
+
<p>${esc(r.why || 'The demo starts behind a login.')}</p>
|
|
411
|
+
<p style="font-size:13px;color:var(--muted)"><b>This stays on your computer.</b> What you type is written to one local file — <code>${esc(settings?.envPath || '.env')}</code>, readable by you only — so Retake can sign in during recording. It is not sent to the agent, to any server, or to us; the agent only learns that the names are set. Use a <b>demo account</b>: the result is a video.</p>
|
|
412
|
+
<form id="secForm" autocomplete="off">
|
|
413
|
+
${r.names.map((n) => `<label style="display:block;margin:10px 0 4px;font-size:12.5px;letter-spacing:.04em;color:var(--muted)">${esc(n)}</label>
|
|
414
|
+
<input name="${esc(n)}" type="${isSecret(n) ? 'password' : 'text'}" autocomplete="${isSecret(n) ? 'new-password' : 'off'}" style="width:100%;border:1px solid var(--rule2);border-radius:10px;padding:10px 12px;font:inherit" />`).join('')}
|
|
415
|
+
<div class="row" style="margin-top:16px">
|
|
416
|
+
<button class="btn" type="button" data-no>Not now</button>
|
|
417
|
+
<button class="btn" type="submit" style="background:var(--accent);border-color:var(--accent);color:#fff">Save to .env</button>
|
|
418
|
+
</div>
|
|
419
|
+
</form>
|
|
420
|
+
</div>`;
|
|
421
|
+
const close = () => { veil.remove(); secretOpen.delete(r.id); };
|
|
422
|
+
veil.querySelector('[data-no]').onclick = close;
|
|
423
|
+
veil.querySelector('#secForm').onsubmit = async (e) => {
|
|
424
|
+
e.preventDefault();
|
|
425
|
+
const values = {};
|
|
426
|
+
for (const n of r.names) { const v = veil.querySelector(`input[name="${n}"]`).value; if (v.trim()) values[n] = v; }
|
|
427
|
+
if (!Object.keys(values).length) return;
|
|
428
|
+
try { await api('/api/secrets/fill', { method: 'POST', body: JSON.stringify({ id: r.id, values }) }); close(); }
|
|
429
|
+
catch (err) { veil.querySelector('h3').textContent = 'Could not save: ' + err.message; }
|
|
430
|
+
};
|
|
431
|
+
document.body.appendChild(veil);
|
|
432
|
+
setTimeout(() => veil.querySelector('input')?.focus(), 40);
|
|
433
|
+
}
|
|
434
|
+
|
|
358
435
|
// ---- the live feed: an agent working shows up here as it works -------------
|
|
359
436
|
function watchActivity() {
|
|
360
437
|
const es = new EventSource('/api/activity/stream');
|
|
@@ -372,8 +449,10 @@ function watchActivity() {
|
|
|
372
449
|
if (window.__boot && d.boot && d.boot !== window.__boot) location.reload();
|
|
373
450
|
window.__boot = d.boot || window.__boot;
|
|
374
451
|
apply(d);
|
|
452
|
+
for (const r of d.secrets || []) askSecrets(r);
|
|
375
453
|
});
|
|
376
454
|
es.addEventListener('line', (ev) => apply(JSON.parse(ev.data)));
|
|
455
|
+
es.addEventListener('secrets', (ev) => askSecrets(JSON.parse(ev.data)));
|
|
377
456
|
es.addEventListener('finished', async (ev) => { apply(JSON.parse(ev.data)); await drawDemos(); await newest(); setTimeout(() => $('live').classList.remove('show'), 8000); });
|
|
378
457
|
es.onerror = () => { es.close(); setTimeout(watchActivity, 3000); };
|
|
379
458
|
}
|
package/dist/ui/guide.html
CHANGED
|
@@ -75,8 +75,9 @@
|
|
|
75
75
|
<section>
|
|
76
76
|
<h3>Good to know</h3>
|
|
77
77
|
<ul>
|
|
78
|
+
<li><b>Two kinds of take.</b> A <i>demo</i> proves how something works — a client walkthrough, a clip for a pull request, a lesson — and keeps everything but your product out of the frame. That is the default. A <i>launch</i> video presents your product in public, and only then do a title card, music and a closing card belong. Your agent asks where it is going; if you shrug, it makes a demo.</li>
|
|
78
79
|
<li>Retake never starts your app without you saying so.</li>
|
|
79
|
-
<li>
|
|
80
|
+
<li><b>Apps behind a login:</b> when the agent needs a sign-in, a form appears here asking for a demo account. What you type is written to one local file (<code>.env</code> in your workspace) and never sent to the agent or anywhere else. The sign-in runs before the camera and is cut off; the session is kept, so later takes skip it. For codes from your phone, SSO or a captcha: <code>retake signin demos/<name>.yaml</code> — log in once by hand, Retake keeps the session.</li>
|
|
80
81
|
<li>A failed step names what was on screen at the time — read the proof log before guessing.</li>
|
|
81
82
|
</ul>
|
|
82
83
|
</section>
|
package/dist/ui/index.html
CHANGED
|
@@ -402,7 +402,7 @@ function fillSettings(d) {
|
|
|
402
402
|
const st = d.settings || {};
|
|
403
403
|
$('sPreset').value = st.preset || 'post-landscape';
|
|
404
404
|
$('sLayout').value = (st.layout === 'band' || !st.layout) ? 'default' : st.layout;
|
|
405
|
-
$('sCamera').value = st.camera || '
|
|
405
|
+
$('sCamera').value = st.camera || 'static';
|
|
406
406
|
$('sCursor').value = st.cursor || 'default';
|
|
407
407
|
paintAction();
|
|
408
408
|
}
|
|
@@ -499,7 +499,7 @@ function openScene(label, t) {
|
|
|
499
499
|
sceneOpen = label;
|
|
500
500
|
$('sceneWho').textContent = `Scene “${label}” · ${t.toFixed(1)}s`;
|
|
501
501
|
$('sceneCap').value = sc?.caption || '';
|
|
502
|
-
$('sceneCam').value = typeof sc?.camera === 'number' ? String(sc.camera) : (sc?.camera || '
|
|
502
|
+
$('sceneCam').value = typeof sc?.camera === 'number' ? String(sc.camera) : (sc?.camera || 'static');
|
|
503
503
|
$('sceneEdit').classList.add('show');
|
|
504
504
|
$('scrub').querySelectorAll('b').forEach((b) => b.classList.toggle('on', b.dataset.label === label));
|
|
505
505
|
const v = $('stage').querySelector('video'); if (v) { v.currentTime = t; v.pause(); }
|
package/dist/ui/server.js
CHANGED
|
@@ -33,11 +33,12 @@ import { draftManifest, loadDotenv, pickProvider, proposeEdits, providerStatus,
|
|
|
33
33
|
import { applyEdits, receiptsFor } from "../edits.js";
|
|
34
34
|
import { dryRun } from "../dryrun.js";
|
|
35
35
|
import { startOperator, getSession, addPending, answerPending, markDone, stopSession } from "../operator/run.js";
|
|
36
|
-
import { gifskiBin, makeGif } from "../render.js";
|
|
36
|
+
import { ffmpegBin, gifskiBin, makeGif } from "../render.js";
|
|
37
37
|
import { captureHash } from "../record.js";
|
|
38
38
|
import { digest } from "../digest.js";
|
|
39
39
|
import { startOffer, startApp, listeningPorts } from "../appserver.js";
|
|
40
40
|
import { PKG_ROOT, PROJECT_ROOT, VERSION, entry } from "../paths.js";
|
|
41
|
+
import { SECRET_NAME, missingSecrets, writeEnvFile } from "../env.js";
|
|
41
42
|
const ROOT = PROJECT_ROOT;
|
|
42
43
|
const DEMOS = path.join(ROOT, "demos");
|
|
43
44
|
/** Where takes land. Default outputs/ in the project; changeable in Settings
|
|
@@ -117,7 +118,7 @@ const readBody = (req) => new Promise((ok) => {
|
|
|
117
118
|
req.on("end", () => ok(b));
|
|
118
119
|
});
|
|
119
120
|
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" };
|
|
121
|
+
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
122
|
/** A short name for "the app at this URL": the last path segment if there
|
|
122
123
|
is one (demo.playwright.dev/todomvc → todomvc), else the host without
|
|
123
124
|
www and TLD (www.saucedemo.com → saucedemo). localhost keeps its port —
|
|
@@ -326,25 +327,7 @@ function startRun(name, mode = {}) {
|
|
|
326
327
|
return run;
|
|
327
328
|
}
|
|
328
329
|
/** 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
|
-
}
|
|
330
|
+
function writeEnv(set) { writeEnvFile(ROOT, set); }
|
|
348
331
|
function envSummary() {
|
|
349
332
|
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
333
|
}
|
|
@@ -405,6 +388,8 @@ function withStaleness(a) {
|
|
|
405
388
|
return { ...a, active: false, stale: true };
|
|
406
389
|
return a;
|
|
407
390
|
}
|
|
391
|
+
const secretRequests = new Map();
|
|
392
|
+
const openSecretRequests = () => [...secretRequests.values()].filter((r) => !r.filled).map(({ id, names, why }) => ({ id, names, why }));
|
|
408
393
|
function noteActivity(b) {
|
|
409
394
|
if (!activity.active && !b.done) {
|
|
410
395
|
activity.active = true;
|
|
@@ -448,9 +433,11 @@ export function serve(port) {
|
|
|
448
433
|
res.writeHead(200, { "content-type": "text/html" });
|
|
449
434
|
return res.end(fs.readFileSync(f));
|
|
450
435
|
}
|
|
451
|
-
|
|
436
|
+
// Both prefixes: the page is served at /landing here and from a site
|
|
437
|
+
// root when deployed, so its asset paths are relative either way.
|
|
438
|
+
const msite = /^(?:\/landing)?\/(media|logos)\/([A-Za-z0-9._-]+)$/.exec(p);
|
|
452
439
|
if (msite && (req.method === "GET" || req.method === "HEAD")) {
|
|
453
|
-
const f = path.join(PKG_ROOT, "site",
|
|
440
|
+
const f = path.join(PKG_ROOT, "site", msite[1], msite[2]);
|
|
454
441
|
if (!fs.existsSync(f))
|
|
455
442
|
return json(res, 404, { error: "not found" });
|
|
456
443
|
res.writeHead(200, { "content-type": MIME[path.extname(f)] ?? "application/octet-stream", "content-length": fs.statSync(f).size });
|
|
@@ -536,6 +523,61 @@ export function serve(port) {
|
|
|
536
523
|
const seen = new Set();
|
|
537
524
|
return json(res, 200, items.filter((x) => !seen.has(x.name) && seen.add(x.name)));
|
|
538
525
|
}
|
|
526
|
+
// ---------- the poster: any frame, a card, or their own file ----------
|
|
527
|
+
// thumbnail.png IS the poster. Every source here just writes it, so the
|
|
528
|
+
// choice costs no re-render and never touches the video.
|
|
529
|
+
const mcov = /^\/api\/cover\/([a-z0-9-]+)$/.exec(p);
|
|
530
|
+
if (mcov && req.method === "POST") {
|
|
531
|
+
const name = mcov[1];
|
|
532
|
+
const dir = path.join(outRoot(), name);
|
|
533
|
+
const target = path.join(dir, "thumbnail.png");
|
|
534
|
+
if (!fs.existsSync(dir))
|
|
535
|
+
return json(res, 404, { error: `no take for ${name}` });
|
|
536
|
+
const b = JSON.parse(await readBody(req));
|
|
537
|
+
if (b.auto) {
|
|
538
|
+
fs.rmSync(path.join(dir, ".poster"), { force: true });
|
|
539
|
+
return json(res, 200, { ok: true, poster: "auto — the next render takes it from the thumbnail scene" });
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
if (b.still) {
|
|
543
|
+
// A scene still, by its file name under stills/.
|
|
544
|
+
const src = path.join(dir, "stills", path.basename(b.still));
|
|
545
|
+
if (!fs.existsSync(src))
|
|
546
|
+
return json(res, 404, { error: `no still ${b.still}` });
|
|
547
|
+
fs.copyFileSync(src, target);
|
|
548
|
+
}
|
|
549
|
+
else if (b.file) {
|
|
550
|
+
// A candidate already in the take (cover.png, cover-titled.png).
|
|
551
|
+
const src = path.join(dir, path.basename(b.file));
|
|
552
|
+
if (!fs.existsSync(src))
|
|
553
|
+
return json(res, 404, { error: `no file ${b.file}` });
|
|
554
|
+
fs.copyFileSync(src, target);
|
|
555
|
+
}
|
|
556
|
+
else if (typeof b.at === "number") {
|
|
557
|
+
// Any moment of the finished video — usually the best frame is
|
|
558
|
+
// nowhere near a scene marker.
|
|
559
|
+
const mp4 = path.join(dir, "demo.mp4");
|
|
560
|
+
if (!fs.existsSync(mp4))
|
|
561
|
+
return json(res, 404, { error: "no demo.mp4" });
|
|
562
|
+
execFileSync(ffmpegBin(), ["-y", "-loglevel", "error", "-ss", Math.max(0, b.at).toFixed(2), "-i", mp4, "-frames:v", "1", target]);
|
|
563
|
+
}
|
|
564
|
+
else if (b.dataUrl) {
|
|
565
|
+
// Their own image.
|
|
566
|
+
const m2 = /^data:image\/(png|jpe?g|webp);base64,(.+)$/.exec(b.dataUrl);
|
|
567
|
+
if (!m2)
|
|
568
|
+
return json(res, 400, { error: "expected a png, jpeg or webp data URL" });
|
|
569
|
+
fs.writeFileSync(target, Buffer.from(m2[2], "base64"));
|
|
570
|
+
}
|
|
571
|
+
else
|
|
572
|
+
return json(res, 400, { error: "give a still, a file, an `at` time, or a dataUrl" });
|
|
573
|
+
}
|
|
574
|
+
catch (e) {
|
|
575
|
+
return json(res, 500, { error: e.message });
|
|
576
|
+
}
|
|
577
|
+
// The choice survives re-renders; "auto" hands it back to the scene default.
|
|
578
|
+
fs.writeFileSync(path.join(dir, ".poster"), JSON.stringify({ ...b, dataUrl: b.dataUrl ? "(uploaded)" : undefined, at: b.at }));
|
|
579
|
+
return json(res, 200, { ok: true, poster: "thumbnail.png" });
|
|
580
|
+
}
|
|
539
581
|
if (p === "/api/trash/clear" && req.method === "POST") {
|
|
540
582
|
const trash = path.join(ROOT, ".trash");
|
|
541
583
|
let n = 0;
|
|
@@ -720,9 +762,50 @@ export function serve(port) {
|
|
|
720
762
|
}
|
|
721
763
|
if (p === "/api/activity" && req.method === "GET")
|
|
722
764
|
return json(res, 200, withStaleness(activity));
|
|
765
|
+
// ---------- secrets: asked for by name, typed into the window, written here ----------
|
|
766
|
+
if (p === "/api/secrets/request" && req.method === "POST") {
|
|
767
|
+
const b = JSON.parse(await readBody(req));
|
|
768
|
+
const names = (b.names ?? []).filter((n) => SECRET_NAME.test(n));
|
|
769
|
+
if (!names.length)
|
|
770
|
+
return json(res, 400, { error: "names must be like APP_PASSWORD" });
|
|
771
|
+
// The same ask twice is one form, not two stacked ones.
|
|
772
|
+
const dup = [...secretRequests.values()].find((x) => !x.filled && x.names.join() === names.join());
|
|
773
|
+
if (dup)
|
|
774
|
+
return json(res, 200, { id: dup.id });
|
|
775
|
+
const r = { id: "s" + Date.now().toString(36), names, why: String(b.why ?? "").slice(0, 300), filled: false, at: Date.now() };
|
|
776
|
+
secretRequests.set(r.id, r);
|
|
777
|
+
for (const w of activityWatchers)
|
|
778
|
+
w({ type: "secrets", data: { id: r.id, names: r.names, why: r.why } });
|
|
779
|
+
return json(res, 200, { id: r.id });
|
|
780
|
+
}
|
|
781
|
+
if (p === "/api/secrets/pending" && req.method === "GET")
|
|
782
|
+
return json(res, 200, openSecretRequests());
|
|
783
|
+
if (p === "/api/secrets/fill" && req.method === "POST") {
|
|
784
|
+
const b = JSON.parse(await readBody(req));
|
|
785
|
+
const r = secretRequests.get(b.id);
|
|
786
|
+
if (!r)
|
|
787
|
+
return json(res, 404, { error: "no such request" });
|
|
788
|
+
const values = {};
|
|
789
|
+
for (const [k, v] of Object.entries(b.values ?? {}))
|
|
790
|
+
if (r.names.includes(k) && typeof v === "string" && v.trim())
|
|
791
|
+
values[k] = v;
|
|
792
|
+
if (!Object.keys(values).length)
|
|
793
|
+
return json(res, 400, { error: "nothing to save" });
|
|
794
|
+
writeEnvFile(ROOT, values);
|
|
795
|
+
if (!missingSecrets(ROOT, r.names).length)
|
|
796
|
+
r.filled = true;
|
|
797
|
+
for (const w of activityWatchers)
|
|
798
|
+
w({ type: "secrets-done", data: { id: r.id, filled: r.filled } });
|
|
799
|
+
return json(res, 200, { ok: true, filled: r.filled, set: Object.keys(values) });
|
|
800
|
+
}
|
|
801
|
+
const msec = /^\/api\/secrets\/(s[a-z0-9]+)$/.exec(p);
|
|
802
|
+
if (msec && req.method === "GET") {
|
|
803
|
+
const r = secretRequests.get(msec[1]);
|
|
804
|
+
return r ? json(res, 200, { filled: r.filled, missing: missingSecrets(ROOT, r.names) }) : json(res, 404, { error: "no such request" });
|
|
805
|
+
}
|
|
723
806
|
if (p === "/api/activity/stream" && req.method === "GET") {
|
|
724
807
|
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`);
|
|
808
|
+
res.write(`event: state\ndata: ${JSON.stringify({ ...activity, boot: BOOT, secrets: openSecretRequests() })}\n\n`);
|
|
726
809
|
const send = (ev) => res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`);
|
|
727
810
|
activityWatchers.add(send);
|
|
728
811
|
const beat = setInterval(() => res.write(": ping\n\n"), 25_000);
|
|
@@ -859,7 +942,7 @@ export function serve(port) {
|
|
|
859
942
|
return json(res, 200, { provider, selected, models: [{ id: selected, name: selected || "Configured default" }] });
|
|
860
943
|
}
|
|
861
944
|
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 });
|
|
945
|
+
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
946
|
const mopen = /^\/api\/open\/([a-z0-9-]+)$/.exec(p);
|
|
864
947
|
if (mopen && req.method === "POST") {
|
|
865
948
|
const dir = path.join(outRoot(), mopen[1]);
|
|
@@ -1023,7 +1106,7 @@ export function serve(port) {
|
|
|
1023
1106
|
if (b.camera === "static")
|
|
1024
1107
|
node.set("camera", "static");
|
|
1025
1108
|
else if (b.camera === "auto")
|
|
1026
|
-
node.
|
|
1109
|
+
node.set("camera", "auto");
|
|
1027
1110
|
else if (typeof b.camera === "object")
|
|
1028
1111
|
node.set("camera", b.camera);
|
|
1029
1112
|
}
|
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.3.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,11 @@
|
|
|
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/index.html",
|
|
44
|
+
"site/logos"
|
|
42
45
|
],
|
|
43
46
|
"scripts": {
|
|
44
47
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/ui && cp src/ui/*.html dist/ui/ && chmod +x dist/cli.js",
|