@argo-video/cli 0.10.0 → 0.11.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.
Files changed (55) hide show
  1. package/README.md +13 -0
  2. package/dist/camera.d.ts +12 -4
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +98 -20
  5. package/dist/camera.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +1 -0
  8. package/dist/cli.js.map +1 -1
  9. package/dist/cursor.d.ts +24 -0
  10. package/dist/cursor.d.ts.map +1 -0
  11. package/dist/cursor.js +110 -0
  12. package/dist/cursor.js.map +1 -0
  13. package/dist/export.d.ts +2 -0
  14. package/dist/export.d.ts.map +1 -1
  15. package/dist/export.js +24 -12
  16. package/dist/export.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/overlays/types.d.ts +38 -1
  22. package/dist/overlays/types.d.ts.map +1 -1
  23. package/dist/overlays/types.js +6 -0
  24. package/dist/overlays/types.js.map +1 -1
  25. package/dist/parse-playwright.d.ts.map +1 -1
  26. package/dist/parse-playwright.js +5 -3
  27. package/dist/parse-playwright.js.map +1 -1
  28. package/dist/pipeline.d.ts.map +1 -1
  29. package/dist/pipeline.js +69 -34
  30. package/dist/pipeline.js.map +1 -1
  31. package/dist/preview.d.ts +1 -0
  32. package/dist/preview.d.ts.map +1 -1
  33. package/dist/preview.js +544 -30
  34. package/dist/preview.js.map +1 -1
  35. package/dist/tts/engine.d.ts +12 -0
  36. package/dist/tts/engine.d.ts.map +1 -1
  37. package/dist/tts/engine.js +55 -0
  38. package/dist/tts/engine.js.map +1 -1
  39. package/dist/tts/engines/index.d.ts +4 -0
  40. package/dist/tts/engines/index.d.ts.map +1 -1
  41. package/dist/tts/engines/index.js +3 -0
  42. package/dist/tts/engines/index.js.map +1 -1
  43. package/dist/tts/engines/kokoro.d.ts.map +1 -1
  44. package/dist/tts/engines/kokoro.js +24 -17
  45. package/dist/tts/engines/kokoro.js.map +1 -1
  46. package/dist/tts/engines/transformers.d.ts +27 -0
  47. package/dist/tts/engines/transformers.d.ts.map +1 -0
  48. package/dist/tts/engines/transformers.js +104 -0
  49. package/dist/tts/engines/transformers.js.map +1 -0
  50. package/dist/tts/generate.d.ts.map +1 -1
  51. package/dist/tts/generate.js +10 -5
  52. package/dist/tts/generate.js.map +1 -1
  53. package/dist/validate.js +1 -2
  54. package/dist/validate.js.map +1 -1
  55. package/package.json +1 -1
package/dist/preview.js CHANGED
@@ -9,12 +9,15 @@
9
9
  */
10
10
  import { execFile } from 'node:child_process';
11
11
  import { createServer } from 'node:http';
12
- import { readFileSync, existsSync, readdirSync, writeFileSync, statSync, createReadStream } from 'node:fs';
12
+ import { readFileSync, existsSync, readdirSync, writeFileSync, statSync, createReadStream, unlinkSync } from 'node:fs';
13
13
  import { dirname, extname, join, relative, resolve } from 'node:path';
14
14
  import { renderTemplate } from './overlays/templates.js';
15
15
  import { alignClips, schedulePlacements } from './tts/align.js';
16
16
  import { ClipCache } from './tts/cache.js';
17
17
  import { createWavBuffer, parseWavHeader } from './tts/engine.js';
18
+ import { generateSrt, generateVtt } from './subtitles.js';
19
+ import { generateChapterMetadata } from './chapters.js';
20
+ import { exportVideo, checkFfmpeg } from './export.js';
18
21
  const MIME_TYPES = {
19
22
  '.html': 'text/html',
20
23
  '.js': 'text/javascript',
@@ -125,14 +128,22 @@ function createSceneReportFromPlacements(placements, persisted) {
125
128
  })),
126
129
  };
127
130
  }
128
- function loadPreviewData(demoName, argoDir, demosDir) {
131
+ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos') {
129
132
  const demoDir = join(argoDir, demoName);
130
133
  // Required files
131
134
  const timingPath = join(demoDir, '.timing.json');
132
135
  if (!existsSync(timingPath)) {
133
136
  throw new Error(`No timing data found at ${timingPath}. Run 'argo pipeline ${demoName}' first.`);
134
137
  }
135
- const timing = readJsonFile(timingPath, {});
138
+ const rawTiming = readJsonFile(timingPath, {});
139
+ // If the pipeline applied head-trimming, shift timing to match the trimmed MP4
140
+ const metaPath = join(outputDir, `${demoName}.meta.json`);
141
+ const meta = existsSync(metaPath) ? readJsonFile(metaPath, {}) : {};
142
+ const headTrimMs = meta?.export?.headTrimMs ?? 0;
143
+ const timing = {};
144
+ for (const [scene, ms] of Object.entries(rawTiming)) {
145
+ timing[scene] = ms - headTrimMs;
146
+ }
136
147
  // Unified scenes manifest
137
148
  const scenesPath = join(demosDir, `${demoName}.scenes.json`);
138
149
  const scenes = readJsonFile(scenesPath, []);
@@ -148,6 +159,13 @@ function loadPreviewData(demoName, argoDir, demosDir) {
148
159
  const overlays = scenes
149
160
  .filter((s) => s.overlay)
150
161
  .map((s) => ({ scene: s.scene, ...s.overlay }));
162
+ // Extract effects keyed by scene name
163
+ const effects = {};
164
+ for (const s of scenes) {
165
+ if (Array.isArray(s.effects) && s.effects.length > 0) {
166
+ effects[s.scene] = s.effects;
167
+ }
168
+ }
151
169
  // Scene durations
152
170
  const sdPath = join(demoDir, '.scene-durations.json');
153
171
  const sceneDurations = readJsonFile(sdPath, {});
@@ -157,12 +175,9 @@ function loadPreviewData(demoName, argoDir, demosDir) {
157
175
  const persistedReport = readJsonFile(reportPath, null);
158
176
  const sceneReport = buildPreviewSceneReport(timing, sceneDurations, persistedReport);
159
177
  const renderedOverlays = buildRenderedOverlays(overlays);
160
- // Pipeline metadata (from last recording)
161
- const projectRoot = dirname(resolve(argoDir));
162
- const metaCandidates = ['videos', 'output'].map(d => join(projectRoot, d, `${demoName}.meta.json`));
163
- const metaPath = metaCandidates.find(p => existsSync(p));
164
- const pipelineMeta = metaPath ? readJsonFile(metaPath, {}) : null;
165
- return { demoName, timing, voiceover, overlays, sceneDurations, sceneReport, renderedOverlays, pipelineMeta };
178
+ // Pipeline metadata (reuse meta loaded above for headTrimMs)
179
+ const pipelineMeta = Object.keys(meta).length > 0 ? meta : null;
180
+ return { demoName, timing, voiceover, overlays, effects, sceneDurations, sceneReport, renderedOverlays, pipelineMeta };
166
181
  }
167
182
  /** List WAV clip files available for a demo. */
168
183
  function listClips(argoDir, demoName) {
@@ -218,6 +233,9 @@ function refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, defaults) {
218
233
  const clips = [];
219
234
  const sceneDurations = {};
220
235
  for (const entry of manifest) {
236
+ // Skip silent scenes (no text = no TTS clip)
237
+ if (!entry.text?.trim())
238
+ continue;
221
239
  const cacheEntry = {
222
240
  scene: entry.scene,
223
241
  text: entry.text,
@@ -235,13 +253,27 @@ function refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, defaults) {
235
253
  sceneDurations[entry.scene] = clipInfo.durationMs;
236
254
  }
237
255
  writeFileSync(join(demoDir, '.scene-durations.json'), JSON.stringify(sceneDurations, null, 2), 'utf-8');
238
- const baseReport = buildPreviewSceneReport(timing, sceneDurations, persistedReport);
239
- const totalDurationMs = baseReport?.totalDurationMs ?? 0;
240
- const aligned = alignClips(timing, clips, totalDurationMs);
241
- writeFileSync(join(demoDir, 'narration-aligned.wav'), createWavBuffer(aligned.samples, 24_000));
256
+ if (clips.length > 0) {
257
+ const baseReport = buildPreviewSceneReport(timing, sceneDurations, persistedReport);
258
+ const totalDurationMs = baseReport?.totalDurationMs ?? 0;
259
+ const aligned = alignClips(timing, clips, totalDurationMs);
260
+ writeFileSync(join(demoDir, 'narration-aligned.wav'), createWavBuffer(aligned.samples, 24_000));
261
+ return {
262
+ sceneDurations,
263
+ sceneReport: createSceneReportFromPlacements(aligned.placements, persistedReport),
264
+ };
265
+ }
266
+ // Silent mode: no clips, build report from timing marks
267
+ const alignedPath = join(demoDir, 'narration-aligned.wav');
268
+ if (existsSync(alignedPath)) {
269
+ try {
270
+ unlinkSync(alignedPath);
271
+ }
272
+ catch { }
273
+ }
242
274
  return {
243
275
  sceneDurations,
244
- sceneReport: createSceneReportFromPlacements(aligned.placements, persistedReport),
276
+ sceneReport: buildPreviewSceneReport(timing, sceneDurations, persistedReport),
245
277
  };
246
278
  }
247
279
  async function runPreviewTtsGenerate(manifestPath) {
@@ -261,24 +293,24 @@ async function runPreviewTtsGenerate(manifestPath) {
261
293
  export async function startPreviewServer(options) {
262
294
  const argoDir = options.argoDir ?? '.argo';
263
295
  const demosDir = options.demosDir ?? 'demos';
296
+ const outputDir = options.outputDir ?? 'videos';
264
297
  const port = options.port ?? 0; // 0 = auto-assign
265
298
  const demoName = options.demoName;
266
299
  const demoDir = join(argoDir, demoName);
267
300
  // Prefer exported MP4 (has keyframes for seeking) over raw WebM (no cue points)
268
301
  const webmPath = join(demoDir, 'video.webm');
269
- const projectRoot = dirname(resolve(argoDir));
270
- const mp4Candidates = ['videos', 'output'].map(d => join(projectRoot, d, `${demoName}.mp4`));
271
- const videoPath = mp4Candidates.find(p => existsSync(p)) ?? webmPath;
302
+ const mp4Path = join(outputDir, `${demoName}.mp4`);
303
+ let videoPath = existsSync(mp4Path) ? mp4Path : webmPath;
272
304
  if (!existsSync(videoPath)) {
273
305
  throw new Error(`No recording found for '${demoName}'. Run 'argo pipeline ${demoName}' first.`);
274
306
  }
275
- const videoMime = videoPath.endsWith('.mp4') ? 'video/mp4' : 'video/webm';
307
+ let videoMime = videoPath.endsWith('.mp4') ? 'video/mp4' : 'video/webm';
276
308
  const server = createServer(async (req, res) => {
277
309
  const url = req.url ?? '/';
278
310
  try {
279
311
  // --- API routes ---
280
312
  if (url === '/api/data') {
281
- const data = loadPreviewData(demoName, argoDir, demosDir);
313
+ const data = loadPreviewData(demoName, argoDir, demosDir, outputDir);
282
314
  res.writeHead(200, { 'Content-Type': 'application/json' });
283
315
  res.end(JSON.stringify(data));
284
316
  return;
@@ -343,11 +375,41 @@ export async function startPreviewServer(options) {
343
375
  writeFileSync(scenesPath, JSON.stringify(scenes, null, 2) + '\n', 'utf-8');
344
376
  }
345
377
  // Reload and re-render overlays
346
- const data = loadPreviewData(demoName, argoDir, demosDir);
378
+ const data = loadPreviewData(demoName, argoDir, demosDir, outputDir);
347
379
  res.writeHead(200, { 'Content-Type': 'application/json' });
348
380
  res.end(JSON.stringify({ ok: true, changed, renderedOverlays: data.renderedOverlays }));
349
381
  return;
350
382
  }
383
+ // Save effects into unified .scenes.json
384
+ if (url === '/api/effects' && req.method === 'POST') {
385
+ const chunks = [];
386
+ for await (const chunk of req)
387
+ chunks.push(chunk);
388
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
389
+ const scenesPath = join(demosDir, `${demoName}.scenes.json`);
390
+ const scenes = readJsonFile(scenesPath, []);
391
+ let changed = false;
392
+ for (const entry of scenes) {
393
+ const posted = body[entry.scene];
394
+ if (posted && posted.length > 0) {
395
+ const newEffects = JSON.stringify(posted);
396
+ if (JSON.stringify(entry.effects) !== newEffects) {
397
+ entry.effects = JSON.parse(newEffects);
398
+ changed = true;
399
+ }
400
+ }
401
+ else if (entry.effects) {
402
+ delete entry.effects;
403
+ changed = true;
404
+ }
405
+ }
406
+ if (changed) {
407
+ writeFileSync(scenesPath, JSON.stringify(scenes, null, 2) + '\n', 'utf-8');
408
+ }
409
+ res.writeHead(200, { 'Content-Type': 'application/json' });
410
+ res.end(JSON.stringify({ ok: true, changed }));
411
+ return;
412
+ }
351
413
  // Regenerate a single TTS clip
352
414
  if (url === '/api/regen-clip' && req.method === 'POST') {
353
415
  const chunks = [];
@@ -389,6 +451,77 @@ export async function startPreviewServer(options) {
389
451
  }
390
452
  return;
391
453
  }
454
+ // Export-only: re-align audio + chapters + subtitles + export MP4 (no re-recording)
455
+ if (url === '/api/export' && req.method === 'POST') {
456
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' });
457
+ try {
458
+ checkFfmpeg();
459
+ // Refresh aligned audio from current clips + timing
460
+ const refreshed = refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, options.ttsDefaults);
461
+ // Read timing for head-trim + placement computation
462
+ const timing = readJsonFile(join(demoDir, '.timing.json'), {});
463
+ const markTimes = Object.values(timing);
464
+ let headTrimMs = 0;
465
+ if (markTimes.length > 0) {
466
+ const firstMarkMs = Math.min(...markTimes);
467
+ headTrimMs = Math.max(0, firstMarkMs - 200);
468
+ if (headTrimMs <= 500)
469
+ headTrimMs = 0;
470
+ }
471
+ // Compute shifted placements for chapters + subtitles
472
+ const placements = refreshed.sceneReport?.scenes?.map(s => ({
473
+ scene: s.scene, startMs: s.startMs - headTrimMs, endMs: s.endMs - headTrimMs,
474
+ })) ?? [];
475
+ // Get video duration
476
+ const { execFileSync } = await import('node:child_process');
477
+ const rawDur = execFileSync('ffprobe', [
478
+ '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', webmPath,
479
+ ], { encoding: 'utf-8' }).trim();
480
+ const totalDurationMs = Math.round(parseFloat(rawDur) * 1000);
481
+ const shiftedDurationMs = totalDurationMs - headTrimMs;
482
+ // Generate chapters
483
+ const chapterMetadataPath = join(demoDir, 'chapters.txt');
484
+ writeFileSync(chapterMetadataPath, generateChapterMetadata(placements, shiftedDurationMs), 'utf-8');
485
+ // Generate subtitles
486
+ const scenesPath = join(demosDir, `${demoName}.scenes.json`);
487
+ const scenes = readJsonFile(scenesPath, []);
488
+ const sceneTexts = {};
489
+ for (const entry of scenes) {
490
+ if (entry.scene && entry.text)
491
+ sceneTexts[entry.scene] = entry.text;
492
+ }
493
+ try {
494
+ const { mkdirSync } = await import('node:fs');
495
+ mkdirSync(outputDir, { recursive: true });
496
+ writeFileSync(join(outputDir, `${demoName}.srt`), generateSrt(placements, sceneTexts), 'utf-8');
497
+ writeFileSync(join(outputDir, `${demoName}.vtt`), generateVtt(placements, sceneTexts), 'utf-8');
498
+ }
499
+ catch { /* subtitles are best-effort */ }
500
+ // Export
501
+ await exportVideo({
502
+ demoName,
503
+ argoDir,
504
+ outputDir,
505
+ chapterMetadataPath,
506
+ headTrimMs: headTrimMs > 0 ? headTrimMs : undefined,
507
+ });
508
+ // Switch to serving the new MP4
509
+ if (existsSync(mp4Path)) {
510
+ videoPath = mp4Path;
511
+ videoMime = 'video/mp4';
512
+ }
513
+ // Switch to serving the new MP4
514
+ if (existsSync(mp4Path)) {
515
+ videoPath = mp4Path;
516
+ videoMime = 'video/mp4';
517
+ }
518
+ res.end(JSON.stringify({ ok: true }));
519
+ }
520
+ catch (err) {
521
+ res.end(JSON.stringify({ ok: false, error: err.message }));
522
+ }
523
+ return;
524
+ }
392
525
  // --- Static file serving ---
393
526
  // Serve video with Range request support (required for seeking)
394
527
  if (url === '/video' || url === '/video.webm') {
@@ -428,7 +561,7 @@ export async function startPreviewServer(options) {
428
561
  }
429
562
  // Root — serve the preview HTML
430
563
  if (url === '/' || url === '/index.html') {
431
- const data = loadPreviewData(demoName, argoDir, demosDir);
564
+ const data = loadPreviewData(demoName, argoDir, demosDir, outputDir);
432
565
  const html = getPreviewHtml(data);
433
566
  res.writeHead(200, { 'Content-Type': 'text/html' });
434
567
  res.end(html);
@@ -1043,6 +1176,75 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1043
1176
  letter-spacing: 0.04em;
1044
1177
  margin-bottom: 6px;
1045
1178
  }
1179
+
1180
+ .effects-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
1181
+ .effects-section .section-title {
1182
+ font-size: 11px;
1183
+ color: var(--text-muted);
1184
+ text-transform: uppercase;
1185
+ letter-spacing: 0.04em;
1186
+ margin-bottom: 6px;
1187
+ display: flex;
1188
+ align-items: center;
1189
+ gap: 6px;
1190
+ }
1191
+ .effect-entry { padding: 6px 0; border-bottom: 1px solid var(--border); }
1192
+ .effect-entry:last-child { border-bottom: none; }
1193
+ .btn-sm { padding: 2px 6px; font-size: 11px; line-height: 1; min-width: auto; }
1194
+ .btn-danger { color: var(--error); border-color: var(--error); }
1195
+ .btn-danger:hover { background: var(--error); color: #fff; }
1196
+
1197
+ /* Recording overlay */
1198
+ .recording-overlay {
1199
+ display: none;
1200
+ position: fixed;
1201
+ inset: 0;
1202
+ z-index: 99999;
1203
+ background: rgba(0, 0, 0, 0.75);
1204
+ backdrop-filter: blur(4px);
1205
+ align-items: center;
1206
+ justify-content: center;
1207
+ }
1208
+ .recording-overlay.active { display: flex; }
1209
+ .recording-card {
1210
+ background: var(--surface);
1211
+ border: 1px solid var(--border);
1212
+ border-radius: 12px;
1213
+ padding: 40px 48px;
1214
+ text-align: center;
1215
+ max-width: 400px;
1216
+ }
1217
+ .recording-spinner {
1218
+ width: 32px;
1219
+ height: 32px;
1220
+ border: 3px solid var(--border);
1221
+ border-top-color: var(--accent);
1222
+ border-radius: 50%;
1223
+ margin: 0 auto 20px;
1224
+ animation: spin 0.8s linear infinite;
1225
+ }
1226
+ @keyframes spin { to { transform: rotate(360deg); } }
1227
+ .recording-title {
1228
+ font-family: var(--mono);
1229
+ font-size: 15px;
1230
+ font-weight: 600;
1231
+ color: var(--text);
1232
+ margin-bottom: 8px;
1233
+ }
1234
+ .recording-subtitle {
1235
+ font-size: 13px;
1236
+ color: var(--text-muted);
1237
+ }
1238
+ .recording-overlay.success .recording-spinner {
1239
+ border-color: var(--success);
1240
+ border-top-color: var(--success);
1241
+ animation: none;
1242
+ }
1243
+ .recording-overlay.error .recording-spinner {
1244
+ border-color: var(--error);
1245
+ border-top-color: var(--error);
1246
+ animation: none;
1247
+ }
1046
1248
  </style>
1047
1249
  </head>
1048
1250
  <body>
@@ -1052,6 +1254,7 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1052
1254
  <div class="actions">
1053
1255
  <a class="trace-link" id="trace-link" href="https://trace.playwright.dev" target="_blank">Open Trace Viewer</a>
1054
1256
  <button class="btn btn-save" id="btn-save" title="Save all changes">Save</button>
1257
+ <button class="btn btn-rerecord" id="btn-export" title="Re-export video with current audio (no re-recording)">Export</button>
1055
1258
  <button class="btn btn-rerecord" id="btn-rerecord" title="Re-record with current manifest">Re-record</button>
1056
1259
  </div>
1057
1260
  </header>
@@ -1108,6 +1311,14 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1108
1311
  <div class="status" id="status">Ready</div>
1109
1312
  </div>
1110
1313
 
1314
+ <div class="recording-overlay" id="recording-overlay">
1315
+ <div class="recording-card">
1316
+ <div class="recording-spinner"></div>
1317
+ <div class="recording-title" id="recording-title">Re-recording pipeline...</div>
1318
+ <div class="recording-subtitle" id="recording-subtitle">All editing is paused while the pipeline runs.</div>
1319
+ </div>
1320
+ </div>
1321
+
1111
1322
  <script>
1112
1323
  // ─── Bootstrap ─────────────────────────────────────────────────────────────
1113
1324
  const DATA = __PREVIEW_DATA__;
@@ -1148,6 +1359,7 @@ const scenes = Object.entries(DATA.timing)
1148
1359
  startMs,
1149
1360
  vo: DATA.voiceover.find(v => v.scene === name),
1150
1361
  overlay: DATA.overlays.find(o => o.scene === name),
1362
+ effects: DATA.effects[name] ?? [],
1151
1363
  rendered: DATA.renderedOverlays[name],
1152
1364
  report: DATA.sceneReport?.scenes?.find(s => s.scene === name),
1153
1365
  }));
@@ -1379,6 +1591,7 @@ function renderSceneList() {
1379
1591
  </div>
1380
1592
  </div>
1381
1593
  \${renderOverlayFields(s)}
1594
+ \${renderEffectsFields(s)}
1382
1595
  <div class="btn-row">
1383
1596
  <button class="btn btn-undo" data-scene="\${esc(s.name)}" onclick="undoScene('\${esc(s.name)}')" style="display:none" title="Revert to last saved state">Undo</button>
1384
1597
  <span class="btn-group"><button class="btn" onclick="previewScene('\${esc(s.name)}')" title="Play this scene">&#9654;</button><button class="btn" onclick="pausePreview()" title="Pause">&#9646;&#9646;</button></span>
@@ -1432,8 +1645,9 @@ function renderSceneList() {
1432
1645
 
1433
1646
  sceneList.appendChild(card);
1434
1647
 
1435
- // Wire overlay listeners AFTER appendChild so document.querySelector can find the card
1648
+ // Wire overlay + effect listeners AFTER appendChild so document.querySelector can find the card
1436
1649
  wireOverlayListeners(s.name);
1650
+ wireEffectListeners(s.name);
1437
1651
  }
1438
1652
  }
1439
1653
 
@@ -1563,6 +1777,248 @@ function wireOverlayListeners(sceneName) {
1563
1777
  }
1564
1778
  }
1565
1779
 
1780
+ // ─── Effects section ────────────────────────────────────────────────────────
1781
+ const EFFECT_TYPES = ['confetti', 'spotlight', 'focus-ring', 'dim-around', 'zoom-to'];
1782
+ const CONFETTI_SPREADS = ['burst', 'rain'];
1783
+
1784
+ function renderEffectsFields(s) {
1785
+ const fx = (s.effects || []);
1786
+ return \`
1787
+ <div class="effects-section">
1788
+ <div class="section-title">Effects <button class="btn btn-sm" onclick="addEffect('\${esc(s.name)}')" title="Add effect">+</button></div>
1789
+ <div class="effects-list" data-scene="\${esc(s.name)}">
1790
+ \${fx.map((e, i) => renderSingleEffect(s.name, e, i)).join('')}
1791
+ </div>
1792
+ </div>
1793
+ \`;
1794
+ }
1795
+
1796
+ function renderSingleEffect(sceneName, effect, index) {
1797
+ const type = effect.type || '';
1798
+ let fields = '';
1799
+
1800
+ if (type === 'confetti') {
1801
+ fields = \`
1802
+ <div class="field-group" style="display:flex;gap:8px">
1803
+ <div style="flex:1">
1804
+ <label>Spread</label>
1805
+ <select data-field="effect-spread" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}">
1806
+ \${CONFETTI_SPREADS.map(s => '<option value="' + s + '"' + (effect.spread === s || (!effect.spread && s === 'burst') ? ' selected' : '') + '>' + s + '</option>').join('')}
1807
+ </select>
1808
+ </div>
1809
+ <div style="flex:1">
1810
+ <label>Pieces</label>
1811
+ <input data-field="effect-pieces" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}" type="number" min="10" max="500" step="10" value="\${effect.pieces ?? 150}" placeholder="150">
1812
+ </div>
1813
+ <div style="flex:1">
1814
+ <label>Duration</label>
1815
+ <input data-field="effect-duration" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}" type="number" min="500" max="10000" step="500" value="\${effect.duration ?? 3000}" placeholder="3000">
1816
+ </div>
1817
+ </div>\`;
1818
+ } else if (type === 'spotlight' || type === 'focus-ring' || type === 'dim-around' || type === 'zoom-to') {
1819
+ fields = \`
1820
+ <div class="field-group">
1821
+ <label>Selector</label>
1822
+ <input data-field="effect-selector" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}" value="\${esc(effect.selector ?? '')}" placeholder="CSS selector">
1823
+ </div>
1824
+ <div class="field-group" style="display:flex;gap:8px">
1825
+ <div style="flex:1">
1826
+ <label>Duration</label>
1827
+ <input data-field="effect-duration" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}" type="number" min="500" max="10000" step="500" value="\${effect.duration ?? 3000}" placeholder="3000">
1828
+ </div>
1829
+ \${type === 'spotlight' ? '<div style="flex:1"><label>Padding</label><input data-field="effect-padding" data-scene="' + esc(sceneName) + '" data-effect-idx="' + index + '" type="number" min="0" max="50" value="' + (effect.padding ?? 12) + '"></div>' : ''}
1830
+ \${type === 'focus-ring' ? '<div style="flex:1"><label>Color</label><input data-field="effect-color" data-scene="' + esc(sceneName) + '" data-effect-idx="' + index + '" type="text" value="' + esc(effect.color ?? '#3b82f6') + '" placeholder="#3b82f6"></div>' : ''}
1831
+ \${type === 'zoom-to' ? '<div style="flex:1"><label>Scale</label><input data-field="effect-scale" data-scene="' + esc(sceneName) + '" data-effect-idx="' + index + '" type="number" step="0.5" min="1" max="5" value="' + (effect.scale ?? 2) + '"></div>' : ''}
1832
+ </div>\`;
1833
+ }
1834
+
1835
+ return \`
1836
+ <div class="effect-entry" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}">
1837
+ <div class="field-group" style="display:flex;gap:8px;align-items:end">
1838
+ <div style="flex:1">
1839
+ <label>Effect</label>
1840
+ <select data-field="effect-type" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}">
1841
+ \${EFFECT_TYPES.map(t => '<option value="' + t + '"' + (type === t ? ' selected' : '') + '>' + t + '</option>').join('')}
1842
+ </select>
1843
+ </div>
1844
+ <button class="btn btn-sm btn-danger" onclick="removeEffect('\${esc(sceneName)}', \${index})" title="Remove effect">&times;</button>
1845
+ <button class="btn btn-sm" onclick="previewEffect('\${esc(sceneName)}', \${index})" title="Preview effect">&#9654;</button>
1846
+ </div>
1847
+ \${fields}
1848
+ </div>
1849
+ \`;
1850
+ }
1851
+
1852
+ function addEffect(sceneName) {
1853
+ const s = scenes.find(sc => sc.name === sceneName);
1854
+ if (!s) return;
1855
+ s.effects = s.effects || [];
1856
+ s.effects.push({ type: 'confetti' });
1857
+ refreshEffectsUI(sceneName);
1858
+ markDirty();
1859
+ }
1860
+
1861
+ function removeEffect(sceneName, index) {
1862
+ const s = scenes.find(sc => sc.name === sceneName);
1863
+ if (!s || !s.effects) return;
1864
+ s.effects.splice(index, 1);
1865
+ refreshEffectsUI(sceneName);
1866
+ markDirty();
1867
+ }
1868
+
1869
+ function refreshEffectsUI(sceneName) {
1870
+ const container = document.querySelector('.effects-list[data-scene="' + sceneName + '"]');
1871
+ if (!container) return;
1872
+ const s = scenes.find(sc => sc.name === sceneName);
1873
+ if (!s) return;
1874
+ container.innerHTML = (s.effects || []).map((e, i) => renderSingleEffect(sceneName, e, i)).join('');
1875
+ wireEffectListeners(sceneName);
1876
+ }
1877
+
1878
+ function wireEffectListeners(sceneName) {
1879
+ const container = document.querySelector('.effects-list[data-scene="' + sceneName + '"]');
1880
+ if (!container) return;
1881
+ container.querySelectorAll('[data-field="effect-type"]').forEach(select => {
1882
+ select.addEventListener('change', () => {
1883
+ const idx = Number(select.dataset.effectIdx);
1884
+ const s = scenes.find(sc => sc.name === sceneName);
1885
+ if (s?.effects?.[idx]) {
1886
+ const newType = select.value;
1887
+ s.effects[idx] = { type: newType };
1888
+ refreshEffectsUI(sceneName);
1889
+ }
1890
+ markDirty();
1891
+ });
1892
+ });
1893
+ container.querySelectorAll('[data-field^="effect-"]').forEach(input => {
1894
+ if (input.dataset.field === 'effect-type') return;
1895
+ input.addEventListener('input', () => markDirty());
1896
+ input.addEventListener('change', () => {
1897
+ collectEffectValues(sceneName);
1898
+ markDirty();
1899
+ });
1900
+ });
1901
+ }
1902
+
1903
+ function collectEffectValues(sceneName) {
1904
+ const s = scenes.find(sc => sc.name === sceneName);
1905
+ if (!s?.effects) return;
1906
+ for (let i = 0; i < s.effects.length; i++) {
1907
+ const entry = {};
1908
+ const container = document.querySelector('.effects-list[data-scene="' + sceneName + '"]');
1909
+ if (!container) continue;
1910
+ const typeEl = container.querySelector('[data-field="effect-type"][data-effect-idx="' + i + '"]');
1911
+ entry.type = typeEl?.value ?? s.effects[i].type;
1912
+ const fields = ['spread', 'pieces', 'duration', 'selector', 'padding', 'color', 'scale'];
1913
+ for (const f of fields) {
1914
+ const el = container.querySelector('[data-field="effect-' + f + '"][data-effect-idx="' + i + '"]');
1915
+ if (el) {
1916
+ const v = el.value;
1917
+ if (f === 'pieces' || f === 'duration' || f === 'padding' || f === 'scale') {
1918
+ const n = Number(v);
1919
+ if (Number.isFinite(n)) entry[f] = n;
1920
+ } else if (v) {
1921
+ entry[f] = v;
1922
+ }
1923
+ }
1924
+ }
1925
+ s.effects[i] = entry;
1926
+ }
1927
+ }
1928
+
1929
+ function collectAllEffects() {
1930
+ const result = {};
1931
+ for (const s of scenes) {
1932
+ collectEffectValues(s.name);
1933
+ if (s.effects && s.effects.length > 0) {
1934
+ result[s.name] = s.effects;
1935
+ }
1936
+ }
1937
+ return result;
1938
+ }
1939
+
1940
+ async function saveEffects() {
1941
+ const fx = collectAllEffects();
1942
+ await fetch('/api/effects', {
1943
+ method: 'POST',
1944
+ headers: { 'Content-Type': 'application/json' },
1945
+ body: JSON.stringify(fx),
1946
+ });
1947
+ }
1948
+
1949
+ function previewEffect(sceneName, index) {
1950
+ const s = scenes.find(sc => sc.name === sceneName);
1951
+ if (!s?.effects?.[index]) return;
1952
+ collectEffectValues(sceneName);
1953
+ const effect = s.effects[index];
1954
+ if (effect.type === 'confetti') {
1955
+ fireConfettiPreview(effect);
1956
+ }
1957
+ // Camera effects need a target in the recorded page — show status hint
1958
+ if (['spotlight', 'focus-ring', 'dim-around', 'zoom-to'].includes(effect.type)) {
1959
+ setStatus('Camera effects are applied during recording — re-record to see changes', 'saving');
1960
+ setTimeout(() => { statusEl.textContent = 'Ready'; statusEl.className = 'status'; }, 2500);
1961
+ }
1962
+ }
1963
+
1964
+ function fireConfettiPreview(effect) {
1965
+ const spread = effect.spread ?? 'burst';
1966
+ const pieces = effect.pieces ?? 150;
1967
+ const duration = effect.duration ?? 3000;
1968
+ const fadeOut = 800;
1969
+ const colors = ['#3b82f6', '#06b6d4', '#4ade80', '#f59e0b', '#ef4444', '#a78bfa'];
1970
+ const id = 'argo-confetti-preview';
1971
+ document.getElementById(id)?.remove();
1972
+
1973
+ const videoContainer = document.querySelector('.video-container');
1974
+ const canvas = document.createElement('canvas');
1975
+ canvas.id = id;
1976
+ canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10';
1977
+ videoContainer.appendChild(canvas);
1978
+ canvas.width = videoContainer.offsetWidth;
1979
+ canvas.height = videoContainer.offsetHeight;
1980
+ const ctx = canvas.getContext('2d');
1981
+
1982
+ const particles = [];
1983
+ for (let i = 0; i < pieces; i++) {
1984
+ const color = colors[Math.floor(Math.random() * colors.length)];
1985
+ const w = 6 + Math.random() * 8;
1986
+ const h = 4 + Math.random() * 6;
1987
+ const rot = Math.random() * Math.PI * 2;
1988
+ const rv = (Math.random() - 0.5) * 0.2;
1989
+ if (spread === 'burst') {
1990
+ const cx = canvas.width / 2;
1991
+ const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.8;
1992
+ const speed = 4 + Math.random() * 8;
1993
+ particles.push({ x: cx + (Math.random() - 0.5) * 40, y: -10, w, h, color, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, rot, rv });
1994
+ } else {
1995
+ particles.push({ x: Math.random() * canvas.width, y: -Math.random() * canvas.height, w, h, color, vx: (Math.random() - 0.5) * 4, vy: 2 + Math.random() * 4, rot, rv });
1996
+ }
1997
+ }
1998
+
1999
+ const startTime = performance.now();
2000
+ function frame() {
2001
+ const elapsed = performance.now() - startTime;
2002
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
2003
+ for (const p of particles) {
2004
+ p.x += p.vx; p.y += p.vy; p.rot += p.rv; p.vy += 0.15;
2005
+ if (spread === 'burst') p.vx *= 0.99;
2006
+ ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot);
2007
+ ctx.fillStyle = p.color; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
2008
+ ctx.restore();
2009
+ }
2010
+ if (elapsed >= duration) {
2011
+ const fadeProgress = Math.min(1, (elapsed - duration) / fadeOut);
2012
+ canvas.style.opacity = String(1 - fadeProgress);
2013
+ if (fadeProgress >= 1) { canvas.remove(); return; }
2014
+ }
2015
+ if (particles.some(p => p.y < canvas.height + 50) || elapsed < duration + fadeOut) {
2016
+ requestAnimationFrame(frame);
2017
+ } else { canvas.remove(); }
2018
+ }
2019
+ requestAnimationFrame(frame);
2020
+ }
2021
+
1566
2022
  const manuallyCollapsed = new Set();
1567
2023
 
1568
2024
  // ─── Actions ───────────────────────────────────────────────────────────────
@@ -1877,6 +2333,7 @@ function snapshotAllScenes() {
1877
2333
  voice: s.vo?.voice ?? '',
1878
2334
  speed: s.vo?.speed ?? '',
1879
2335
  overlay: s.overlay ? JSON.parse(JSON.stringify(s.overlay)) : null,
2336
+ effects: s.effects?.length ? JSON.parse(JSON.stringify(s.effects)) : [],
1880
2337
  });
1881
2338
  }
1882
2339
  }
@@ -1914,6 +2371,11 @@ function isSceneModified(sceneName) {
1914
2371
  if (kicker !== (so.kicker ?? '')) return true;
1915
2372
  if (src !== (so.src ?? '')) return true;
1916
2373
  }
2374
+ // Check effects
2375
+ const s = scenes.find(sc => sc.name === sceneName);
2376
+ const currentEffects = JSON.stringify(s?.effects ?? []);
2377
+ const snapEffects = JSON.stringify(snap.effects ?? []);
2378
+ if (currentEffects !== snapEffects) return true;
1917
2379
  return false;
1918
2380
  }
1919
2381
 
@@ -1941,6 +2403,12 @@ function undoScene(sceneName) {
1941
2403
  if (voiceEl) voiceEl.value = snap.voice;
1942
2404
  const speedEl = card.querySelector('[data-field="speed"]');
1943
2405
  if (speedEl) speedEl.value = snap.speed;
2406
+ // Restore effects
2407
+ const s = scenes.find(sc => sc.name === sceneName);
2408
+ if (s) {
2409
+ s.effects = snap.effects?.length ? JSON.parse(JSON.stringify(snap.effects)) : [];
2410
+ refreshEffectsUI(sceneName);
2411
+ }
1944
2412
  // Restore overlay type (triggers field re-render)
1945
2413
  const typeEl = card.querySelector('[data-field="overlay-type"]');
1946
2414
  if (typeEl) {
@@ -2000,6 +2468,7 @@ document.getElementById('btn-save').addEventListener('click', async () => {
2000
2468
  try {
2001
2469
  await saveVoiceover();
2002
2470
  await saveOverlays();
2471
+ await saveEffects();
2003
2472
  clearDirty();
2004
2473
  setStatus('All changes saved', 'saved');
2005
2474
  saveBtn.textContent = '\\u2713 Saved';
@@ -2015,28 +2484,73 @@ document.getElementById('btn-save').addEventListener('click', async () => {
2015
2484
  }
2016
2485
  });
2017
2486
 
2487
+ // Export button (re-align audio + export MP4, no re-recording)
2488
+ document.getElementById('btn-export').addEventListener('click', async () => {
2489
+ if (isDirty && !confirm('You have unsaved changes. Save before exporting?')) return;
2490
+ if (isDirty) {
2491
+ await saveVoiceover();
2492
+ await saveOverlays();
2493
+ await saveEffects();
2494
+ clearDirty();
2495
+ }
2496
+ const overlay = document.getElementById('recording-overlay');
2497
+ const title = document.getElementById('recording-title');
2498
+ const subtitle = document.getElementById('recording-subtitle');
2499
+ overlay.classList.remove('success', 'error');
2500
+ overlay.classList.add('active');
2501
+ title.textContent = 'Exporting video...';
2502
+ subtitle.textContent = 'Re-aligning audio and exporting MP4.';
2503
+ video.pause();
2504
+ stopAudio();
2505
+ showPlayIcon();
2506
+ try {
2507
+ const resp = await fetch('/api/export', { method: 'POST' });
2508
+ const result = await resp.json();
2509
+ if (!result.ok) throw new Error(result.error);
2510
+ overlay.classList.add('success');
2511
+ title.textContent = 'Export complete!';
2512
+ subtitle.textContent = 'Reloading preview...';
2513
+ setTimeout(() => location.reload(), 1500);
2514
+ } catch (err) {
2515
+ overlay.classList.add('error');
2516
+ title.textContent = 'Export failed';
2517
+ subtitle.textContent = err.message;
2518
+ setTimeout(() => overlay.classList.remove('active', 'error'), 5000);
2519
+ }
2520
+ });
2521
+
2018
2522
  // Re-record button
2019
2523
  document.getElementById('btn-rerecord').addEventListener('click', async () => {
2020
2524
  if (isDirty && !confirm('You have unsaved changes. Save before re-recording?')) return;
2021
2525
  if (isDirty) {
2022
2526
  await saveVoiceover();
2023
2527
  await saveOverlays();
2528
+ await saveEffects();
2024
2529
  clearDirty();
2025
2530
  }
2026
- const btn = document.getElementById('btn-rerecord');
2027
- btn.disabled = true;
2028
- btn.textContent = 'Recording...';
2029
- setStatus('Re-recording pipeline...', 'saving');
2531
+ const overlay = document.getElementById('recording-overlay');
2532
+ const title = document.getElementById('recording-title');
2533
+ const subtitle = document.getElementById('recording-subtitle');
2534
+ overlay.classList.remove('success', 'error');
2535
+ overlay.classList.add('active');
2536
+ title.textContent = 'Re-recording pipeline...';
2537
+ subtitle.textContent = 'All editing is paused while the pipeline runs.';
2538
+ video.pause();
2539
+ stopAudio();
2540
+ showPlayIcon();
2030
2541
  try {
2031
2542
  const resp = await fetch('/api/rerecord', { method: 'POST' });
2032
2543
  const result = await resp.json();
2033
2544
  if (!result.ok) throw new Error(result.error);
2034
- setStatus('Re-record complete! Reloading...', 'saved');
2545
+ overlay.classList.add('success');
2546
+ title.textContent = 'Recording complete!';
2547
+ subtitle.textContent = 'Reloading preview...';
2035
2548
  setTimeout(() => location.reload(), 1500);
2036
2549
  } catch (err) {
2037
- setStatus('Re-record failed: ' + err.message, 'error');
2038
- btn.disabled = false;
2039
- btn.textContent = 'Re-record';
2550
+ overlay.classList.add('error');
2551
+ title.textContent = 'Recording failed';
2552
+ subtitle.textContent = err.message;
2553
+ setTimeout(() => overlay.classList.remove('active', 'error'), 5000);
2040
2554
  }
2041
2555
  });
2042
2556