@argo-video/cli 0.10.0 → 0.10.1

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/dist/preview.js CHANGED
@@ -125,14 +125,22 @@ function createSceneReportFromPlacements(placements, persisted) {
125
125
  })),
126
126
  };
127
127
  }
128
- function loadPreviewData(demoName, argoDir, demosDir) {
128
+ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos') {
129
129
  const demoDir = join(argoDir, demoName);
130
130
  // Required files
131
131
  const timingPath = join(demoDir, '.timing.json');
132
132
  if (!existsSync(timingPath)) {
133
133
  throw new Error(`No timing data found at ${timingPath}. Run 'argo pipeline ${demoName}' first.`);
134
134
  }
135
- const timing = readJsonFile(timingPath, {});
135
+ const rawTiming = readJsonFile(timingPath, {});
136
+ // If the pipeline applied head-trimming, shift timing to match the trimmed MP4
137
+ const metaPath = join(outputDir, `${demoName}.meta.json`);
138
+ const meta = existsSync(metaPath) ? readJsonFile(metaPath, {}) : {};
139
+ const headTrimMs = meta?.export?.headTrimMs ?? 0;
140
+ const timing = {};
141
+ for (const [scene, ms] of Object.entries(rawTiming)) {
142
+ timing[scene] = ms - headTrimMs;
143
+ }
136
144
  // Unified scenes manifest
137
145
  const scenesPath = join(demosDir, `${demoName}.scenes.json`);
138
146
  const scenes = readJsonFile(scenesPath, []);
@@ -148,6 +156,13 @@ function loadPreviewData(demoName, argoDir, demosDir) {
148
156
  const overlays = scenes
149
157
  .filter((s) => s.overlay)
150
158
  .map((s) => ({ scene: s.scene, ...s.overlay }));
159
+ // Extract effects keyed by scene name
160
+ const effects = {};
161
+ for (const s of scenes) {
162
+ if (Array.isArray(s.effects) && s.effects.length > 0) {
163
+ effects[s.scene] = s.effects;
164
+ }
165
+ }
151
166
  // Scene durations
152
167
  const sdPath = join(demoDir, '.scene-durations.json');
153
168
  const sceneDurations = readJsonFile(sdPath, {});
@@ -157,12 +172,9 @@ function loadPreviewData(demoName, argoDir, demosDir) {
157
172
  const persistedReport = readJsonFile(reportPath, null);
158
173
  const sceneReport = buildPreviewSceneReport(timing, sceneDurations, persistedReport);
159
174
  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 };
175
+ // Pipeline metadata (reuse meta loaded above for headTrimMs)
176
+ const pipelineMeta = Object.keys(meta).length > 0 ? meta : null;
177
+ return { demoName, timing, voiceover, overlays, effects, sceneDurations, sceneReport, renderedOverlays, pipelineMeta };
166
178
  }
167
179
  /** List WAV clip files available for a demo. */
168
180
  function listClips(argoDir, demoName) {
@@ -261,14 +273,14 @@ async function runPreviewTtsGenerate(manifestPath) {
261
273
  export async function startPreviewServer(options) {
262
274
  const argoDir = options.argoDir ?? '.argo';
263
275
  const demosDir = options.demosDir ?? 'demos';
276
+ const outputDir = options.outputDir ?? 'videos';
264
277
  const port = options.port ?? 0; // 0 = auto-assign
265
278
  const demoName = options.demoName;
266
279
  const demoDir = join(argoDir, demoName);
267
280
  // Prefer exported MP4 (has keyframes for seeking) over raw WebM (no cue points)
268
281
  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;
282
+ const mp4Path = join(outputDir, `${demoName}.mp4`);
283
+ const videoPath = existsSync(mp4Path) ? mp4Path : webmPath;
272
284
  if (!existsSync(videoPath)) {
273
285
  throw new Error(`No recording found for '${demoName}'. Run 'argo pipeline ${demoName}' first.`);
274
286
  }
@@ -278,7 +290,7 @@ export async function startPreviewServer(options) {
278
290
  try {
279
291
  // --- API routes ---
280
292
  if (url === '/api/data') {
281
- const data = loadPreviewData(demoName, argoDir, demosDir);
293
+ const data = loadPreviewData(demoName, argoDir, demosDir, outputDir);
282
294
  res.writeHead(200, { 'Content-Type': 'application/json' });
283
295
  res.end(JSON.stringify(data));
284
296
  return;
@@ -343,11 +355,41 @@ export async function startPreviewServer(options) {
343
355
  writeFileSync(scenesPath, JSON.stringify(scenes, null, 2) + '\n', 'utf-8');
344
356
  }
345
357
  // Reload and re-render overlays
346
- const data = loadPreviewData(demoName, argoDir, demosDir);
358
+ const data = loadPreviewData(demoName, argoDir, demosDir, outputDir);
347
359
  res.writeHead(200, { 'Content-Type': 'application/json' });
348
360
  res.end(JSON.stringify({ ok: true, changed, renderedOverlays: data.renderedOverlays }));
349
361
  return;
350
362
  }
363
+ // Save effects into unified .scenes.json
364
+ if (url === '/api/effects' && req.method === 'POST') {
365
+ const chunks = [];
366
+ for await (const chunk of req)
367
+ chunks.push(chunk);
368
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
369
+ const scenesPath = join(demosDir, `${demoName}.scenes.json`);
370
+ const scenes = readJsonFile(scenesPath, []);
371
+ let changed = false;
372
+ for (const entry of scenes) {
373
+ const posted = body[entry.scene];
374
+ if (posted && posted.length > 0) {
375
+ const newEffects = JSON.stringify(posted);
376
+ if (JSON.stringify(entry.effects) !== newEffects) {
377
+ entry.effects = JSON.parse(newEffects);
378
+ changed = true;
379
+ }
380
+ }
381
+ else if (entry.effects) {
382
+ delete entry.effects;
383
+ changed = true;
384
+ }
385
+ }
386
+ if (changed) {
387
+ writeFileSync(scenesPath, JSON.stringify(scenes, null, 2) + '\n', 'utf-8');
388
+ }
389
+ res.writeHead(200, { 'Content-Type': 'application/json' });
390
+ res.end(JSON.stringify({ ok: true, changed }));
391
+ return;
392
+ }
351
393
  // Regenerate a single TTS clip
352
394
  if (url === '/api/regen-clip' && req.method === 'POST') {
353
395
  const chunks = [];
@@ -428,7 +470,7 @@ export async function startPreviewServer(options) {
428
470
  }
429
471
  // Root — serve the preview HTML
430
472
  if (url === '/' || url === '/index.html') {
431
- const data = loadPreviewData(demoName, argoDir, demosDir);
473
+ const data = loadPreviewData(demoName, argoDir, demosDir, outputDir);
432
474
  const html = getPreviewHtml(data);
433
475
  res.writeHead(200, { 'Content-Type': 'text/html' });
434
476
  res.end(html);
@@ -1043,6 +1085,75 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1043
1085
  letter-spacing: 0.04em;
1044
1086
  margin-bottom: 6px;
1045
1087
  }
1088
+
1089
+ .effects-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
1090
+ .effects-section .section-title {
1091
+ font-size: 11px;
1092
+ color: var(--text-muted);
1093
+ text-transform: uppercase;
1094
+ letter-spacing: 0.04em;
1095
+ margin-bottom: 6px;
1096
+ display: flex;
1097
+ align-items: center;
1098
+ gap: 6px;
1099
+ }
1100
+ .effect-entry { padding: 6px 0; border-bottom: 1px solid var(--border); }
1101
+ .effect-entry:last-child { border-bottom: none; }
1102
+ .btn-sm { padding: 2px 6px; font-size: 11px; line-height: 1; min-width: auto; }
1103
+ .btn-danger { color: var(--error); border-color: var(--error); }
1104
+ .btn-danger:hover { background: var(--error); color: #fff; }
1105
+
1106
+ /* Recording overlay */
1107
+ .recording-overlay {
1108
+ display: none;
1109
+ position: fixed;
1110
+ inset: 0;
1111
+ z-index: 99999;
1112
+ background: rgba(0, 0, 0, 0.75);
1113
+ backdrop-filter: blur(4px);
1114
+ align-items: center;
1115
+ justify-content: center;
1116
+ }
1117
+ .recording-overlay.active { display: flex; }
1118
+ .recording-card {
1119
+ background: var(--surface);
1120
+ border: 1px solid var(--border);
1121
+ border-radius: 12px;
1122
+ padding: 40px 48px;
1123
+ text-align: center;
1124
+ max-width: 400px;
1125
+ }
1126
+ .recording-spinner {
1127
+ width: 32px;
1128
+ height: 32px;
1129
+ border: 3px solid var(--border);
1130
+ border-top-color: var(--accent);
1131
+ border-radius: 50%;
1132
+ margin: 0 auto 20px;
1133
+ animation: spin 0.8s linear infinite;
1134
+ }
1135
+ @keyframes spin { to { transform: rotate(360deg); } }
1136
+ .recording-title {
1137
+ font-family: var(--mono);
1138
+ font-size: 15px;
1139
+ font-weight: 600;
1140
+ color: var(--text);
1141
+ margin-bottom: 8px;
1142
+ }
1143
+ .recording-subtitle {
1144
+ font-size: 13px;
1145
+ color: var(--text-muted);
1146
+ }
1147
+ .recording-overlay.success .recording-spinner {
1148
+ border-color: var(--success);
1149
+ border-top-color: var(--success);
1150
+ animation: none;
1151
+ }
1152
+ .recording-overlay.error .recording-spinner {
1153
+ border-color: var(--error);
1154
+ border-top-color: var(--error);
1155
+ animation: none;
1156
+ }
1046
1157
  </style>
1047
1158
  </head>
1048
1159
  <body>
@@ -1108,6 +1219,14 @@ const PREVIEW_HTML = `<!DOCTYPE html>
1108
1219
  <div class="status" id="status">Ready</div>
1109
1220
  </div>
1110
1221
 
1222
+ <div class="recording-overlay" id="recording-overlay">
1223
+ <div class="recording-card">
1224
+ <div class="recording-spinner"></div>
1225
+ <div class="recording-title" id="recording-title">Re-recording pipeline...</div>
1226
+ <div class="recording-subtitle" id="recording-subtitle">All editing is paused while the pipeline runs.</div>
1227
+ </div>
1228
+ </div>
1229
+
1111
1230
  <script>
1112
1231
  // ─── Bootstrap ─────────────────────────────────────────────────────────────
1113
1232
  const DATA = __PREVIEW_DATA__;
@@ -1148,6 +1267,7 @@ const scenes = Object.entries(DATA.timing)
1148
1267
  startMs,
1149
1268
  vo: DATA.voiceover.find(v => v.scene === name),
1150
1269
  overlay: DATA.overlays.find(o => o.scene === name),
1270
+ effects: DATA.effects[name] ?? [],
1151
1271
  rendered: DATA.renderedOverlays[name],
1152
1272
  report: DATA.sceneReport?.scenes?.find(s => s.scene === name),
1153
1273
  }));
@@ -1379,6 +1499,7 @@ function renderSceneList() {
1379
1499
  </div>
1380
1500
  </div>
1381
1501
  \${renderOverlayFields(s)}
1502
+ \${renderEffectsFields(s)}
1382
1503
  <div class="btn-row">
1383
1504
  <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
1505
  <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 +1553,9 @@ function renderSceneList() {
1432
1553
 
1433
1554
  sceneList.appendChild(card);
1434
1555
 
1435
- // Wire overlay listeners AFTER appendChild so document.querySelector can find the card
1556
+ // Wire overlay + effect listeners AFTER appendChild so document.querySelector can find the card
1436
1557
  wireOverlayListeners(s.name);
1558
+ wireEffectListeners(s.name);
1437
1559
  }
1438
1560
  }
1439
1561
 
@@ -1563,6 +1685,248 @@ function wireOverlayListeners(sceneName) {
1563
1685
  }
1564
1686
  }
1565
1687
 
1688
+ // ─── Effects section ────────────────────────────────────────────────────────
1689
+ const EFFECT_TYPES = ['confetti', 'spotlight', 'focus-ring', 'dim-around', 'zoom-to'];
1690
+ const CONFETTI_SPREADS = ['burst', 'rain'];
1691
+
1692
+ function renderEffectsFields(s) {
1693
+ const fx = (s.effects || []);
1694
+ return \`
1695
+ <div class="effects-section">
1696
+ <div class="section-title">Effects <button class="btn btn-sm" onclick="addEffect('\${esc(s.name)}')" title="Add effect">+</button></div>
1697
+ <div class="effects-list" data-scene="\${esc(s.name)}">
1698
+ \${fx.map((e, i) => renderSingleEffect(s.name, e, i)).join('')}
1699
+ </div>
1700
+ </div>
1701
+ \`;
1702
+ }
1703
+
1704
+ function renderSingleEffect(sceneName, effect, index) {
1705
+ const type = effect.type || '';
1706
+ let fields = '';
1707
+
1708
+ if (type === 'confetti') {
1709
+ fields = \`
1710
+ <div class="field-group" style="display:flex;gap:8px">
1711
+ <div style="flex:1">
1712
+ <label>Spread</label>
1713
+ <select data-field="effect-spread" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}">
1714
+ \${CONFETTI_SPREADS.map(s => '<option value="' + s + '"' + (effect.spread === s || (!effect.spread && s === 'burst') ? ' selected' : '') + '>' + s + '</option>').join('')}
1715
+ </select>
1716
+ </div>
1717
+ <div style="flex:1">
1718
+ <label>Pieces</label>
1719
+ <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">
1720
+ </div>
1721
+ <div style="flex:1">
1722
+ <label>Duration</label>
1723
+ <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">
1724
+ </div>
1725
+ </div>\`;
1726
+ } else if (type === 'spotlight' || type === 'focus-ring' || type === 'dim-around' || type === 'zoom-to') {
1727
+ fields = \`
1728
+ <div class="field-group">
1729
+ <label>Selector</label>
1730
+ <input data-field="effect-selector" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}" value="\${esc(effect.selector ?? '')}" placeholder="CSS selector">
1731
+ </div>
1732
+ <div class="field-group" style="display:flex;gap:8px">
1733
+ <div style="flex:1">
1734
+ <label>Duration</label>
1735
+ <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">
1736
+ </div>
1737
+ \${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>' : ''}
1738
+ \${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>' : ''}
1739
+ \${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>' : ''}
1740
+ </div>\`;
1741
+ }
1742
+
1743
+ return \`
1744
+ <div class="effect-entry" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}">
1745
+ <div class="field-group" style="display:flex;gap:8px;align-items:end">
1746
+ <div style="flex:1">
1747
+ <label>Effect</label>
1748
+ <select data-field="effect-type" data-scene="\${esc(sceneName)}" data-effect-idx="\${index}">
1749
+ \${EFFECT_TYPES.map(t => '<option value="' + t + '"' + (type === t ? ' selected' : '') + '>' + t + '</option>').join('')}
1750
+ </select>
1751
+ </div>
1752
+ <button class="btn btn-sm btn-danger" onclick="removeEffect('\${esc(sceneName)}', \${index})" title="Remove effect">&times;</button>
1753
+ <button class="btn btn-sm" onclick="previewEffect('\${esc(sceneName)}', \${index})" title="Preview effect">&#9654;</button>
1754
+ </div>
1755
+ \${fields}
1756
+ </div>
1757
+ \`;
1758
+ }
1759
+
1760
+ function addEffect(sceneName) {
1761
+ const s = scenes.find(sc => sc.name === sceneName);
1762
+ if (!s) return;
1763
+ s.effects = s.effects || [];
1764
+ s.effects.push({ type: 'confetti' });
1765
+ refreshEffectsUI(sceneName);
1766
+ markDirty();
1767
+ }
1768
+
1769
+ function removeEffect(sceneName, index) {
1770
+ const s = scenes.find(sc => sc.name === sceneName);
1771
+ if (!s || !s.effects) return;
1772
+ s.effects.splice(index, 1);
1773
+ refreshEffectsUI(sceneName);
1774
+ markDirty();
1775
+ }
1776
+
1777
+ function refreshEffectsUI(sceneName) {
1778
+ const container = document.querySelector('.effects-list[data-scene="' + sceneName + '"]');
1779
+ if (!container) return;
1780
+ const s = scenes.find(sc => sc.name === sceneName);
1781
+ if (!s) return;
1782
+ container.innerHTML = (s.effects || []).map((e, i) => renderSingleEffect(sceneName, e, i)).join('');
1783
+ wireEffectListeners(sceneName);
1784
+ }
1785
+
1786
+ function wireEffectListeners(sceneName) {
1787
+ const container = document.querySelector('.effects-list[data-scene="' + sceneName + '"]');
1788
+ if (!container) return;
1789
+ container.querySelectorAll('[data-field="effect-type"]').forEach(select => {
1790
+ select.addEventListener('change', () => {
1791
+ const idx = Number(select.dataset.effectIdx);
1792
+ const s = scenes.find(sc => sc.name === sceneName);
1793
+ if (s?.effects?.[idx]) {
1794
+ const newType = select.value;
1795
+ s.effects[idx] = { type: newType };
1796
+ refreshEffectsUI(sceneName);
1797
+ }
1798
+ markDirty();
1799
+ });
1800
+ });
1801
+ container.querySelectorAll('[data-field^="effect-"]').forEach(input => {
1802
+ if (input.dataset.field === 'effect-type') return;
1803
+ input.addEventListener('input', () => markDirty());
1804
+ input.addEventListener('change', () => {
1805
+ collectEffectValues(sceneName);
1806
+ markDirty();
1807
+ });
1808
+ });
1809
+ }
1810
+
1811
+ function collectEffectValues(sceneName) {
1812
+ const s = scenes.find(sc => sc.name === sceneName);
1813
+ if (!s?.effects) return;
1814
+ for (let i = 0; i < s.effects.length; i++) {
1815
+ const entry = {};
1816
+ const container = document.querySelector('.effects-list[data-scene="' + sceneName + '"]');
1817
+ if (!container) continue;
1818
+ const typeEl = container.querySelector('[data-field="effect-type"][data-effect-idx="' + i + '"]');
1819
+ entry.type = typeEl?.value ?? s.effects[i].type;
1820
+ const fields = ['spread', 'pieces', 'duration', 'selector', 'padding', 'color', 'scale'];
1821
+ for (const f of fields) {
1822
+ const el = container.querySelector('[data-field="effect-' + f + '"][data-effect-idx="' + i + '"]');
1823
+ if (el) {
1824
+ const v = el.value;
1825
+ if (f === 'pieces' || f === 'duration' || f === 'padding' || f === 'scale') {
1826
+ const n = Number(v);
1827
+ if (Number.isFinite(n)) entry[f] = n;
1828
+ } else if (v) {
1829
+ entry[f] = v;
1830
+ }
1831
+ }
1832
+ }
1833
+ s.effects[i] = entry;
1834
+ }
1835
+ }
1836
+
1837
+ function collectAllEffects() {
1838
+ const result = {};
1839
+ for (const s of scenes) {
1840
+ collectEffectValues(s.name);
1841
+ if (s.effects && s.effects.length > 0) {
1842
+ result[s.name] = s.effects;
1843
+ }
1844
+ }
1845
+ return result;
1846
+ }
1847
+
1848
+ async function saveEffects() {
1849
+ const fx = collectAllEffects();
1850
+ await fetch('/api/effects', {
1851
+ method: 'POST',
1852
+ headers: { 'Content-Type': 'application/json' },
1853
+ body: JSON.stringify(fx),
1854
+ });
1855
+ }
1856
+
1857
+ function previewEffect(sceneName, index) {
1858
+ const s = scenes.find(sc => sc.name === sceneName);
1859
+ if (!s?.effects?.[index]) return;
1860
+ collectEffectValues(sceneName);
1861
+ const effect = s.effects[index];
1862
+ if (effect.type === 'confetti') {
1863
+ fireConfettiPreview(effect);
1864
+ }
1865
+ // Camera effects need a target in the recorded page — show status hint
1866
+ if (['spotlight', 'focus-ring', 'dim-around', 'zoom-to'].includes(effect.type)) {
1867
+ setStatus('Camera effects are applied during recording — re-record to see changes', 'saving');
1868
+ setTimeout(() => { statusEl.textContent = 'Ready'; statusEl.className = 'status'; }, 2500);
1869
+ }
1870
+ }
1871
+
1872
+ function fireConfettiPreview(effect) {
1873
+ const spread = effect.spread ?? 'burst';
1874
+ const pieces = effect.pieces ?? 150;
1875
+ const duration = effect.duration ?? 3000;
1876
+ const fadeOut = 800;
1877
+ const colors = ['#3b82f6', '#06b6d4', '#4ade80', '#f59e0b', '#ef4444', '#a78bfa'];
1878
+ const id = 'argo-confetti-preview';
1879
+ document.getElementById(id)?.remove();
1880
+
1881
+ const videoContainer = document.querySelector('.video-container');
1882
+ const canvas = document.createElement('canvas');
1883
+ canvas.id = id;
1884
+ canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10';
1885
+ videoContainer.appendChild(canvas);
1886
+ canvas.width = videoContainer.offsetWidth;
1887
+ canvas.height = videoContainer.offsetHeight;
1888
+ const ctx = canvas.getContext('2d');
1889
+
1890
+ const particles = [];
1891
+ for (let i = 0; i < pieces; i++) {
1892
+ const color = colors[Math.floor(Math.random() * colors.length)];
1893
+ const w = 6 + Math.random() * 8;
1894
+ const h = 4 + Math.random() * 6;
1895
+ const rot = Math.random() * Math.PI * 2;
1896
+ const rv = (Math.random() - 0.5) * 0.2;
1897
+ if (spread === 'burst') {
1898
+ const cx = canvas.width / 2;
1899
+ const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.8;
1900
+ const speed = 4 + Math.random() * 8;
1901
+ 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 });
1902
+ } else {
1903
+ 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 });
1904
+ }
1905
+ }
1906
+
1907
+ const startTime = performance.now();
1908
+ function frame() {
1909
+ const elapsed = performance.now() - startTime;
1910
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
1911
+ for (const p of particles) {
1912
+ p.x += p.vx; p.y += p.vy; p.rot += p.rv; p.vy += 0.15;
1913
+ if (spread === 'burst') p.vx *= 0.99;
1914
+ ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot);
1915
+ ctx.fillStyle = p.color; ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
1916
+ ctx.restore();
1917
+ }
1918
+ if (elapsed >= duration) {
1919
+ const fadeProgress = Math.min(1, (elapsed - duration) / fadeOut);
1920
+ canvas.style.opacity = String(1 - fadeProgress);
1921
+ if (fadeProgress >= 1) { canvas.remove(); return; }
1922
+ }
1923
+ if (particles.some(p => p.y < canvas.height + 50) || elapsed < duration + fadeOut) {
1924
+ requestAnimationFrame(frame);
1925
+ } else { canvas.remove(); }
1926
+ }
1927
+ requestAnimationFrame(frame);
1928
+ }
1929
+
1566
1930
  const manuallyCollapsed = new Set();
1567
1931
 
1568
1932
  // ─── Actions ───────────────────────────────────────────────────────────────
@@ -1877,6 +2241,7 @@ function snapshotAllScenes() {
1877
2241
  voice: s.vo?.voice ?? '',
1878
2242
  speed: s.vo?.speed ?? '',
1879
2243
  overlay: s.overlay ? JSON.parse(JSON.stringify(s.overlay)) : null,
2244
+ effects: s.effects?.length ? JSON.parse(JSON.stringify(s.effects)) : [],
1880
2245
  });
1881
2246
  }
1882
2247
  }
@@ -1914,6 +2279,11 @@ function isSceneModified(sceneName) {
1914
2279
  if (kicker !== (so.kicker ?? '')) return true;
1915
2280
  if (src !== (so.src ?? '')) return true;
1916
2281
  }
2282
+ // Check effects
2283
+ const s = scenes.find(sc => sc.name === sceneName);
2284
+ const currentEffects = JSON.stringify(s?.effects ?? []);
2285
+ const snapEffects = JSON.stringify(snap.effects ?? []);
2286
+ if (currentEffects !== snapEffects) return true;
1917
2287
  return false;
1918
2288
  }
1919
2289
 
@@ -1941,6 +2311,12 @@ function undoScene(sceneName) {
1941
2311
  if (voiceEl) voiceEl.value = snap.voice;
1942
2312
  const speedEl = card.querySelector('[data-field="speed"]');
1943
2313
  if (speedEl) speedEl.value = snap.speed;
2314
+ // Restore effects
2315
+ const s = scenes.find(sc => sc.name === sceneName);
2316
+ if (s) {
2317
+ s.effects = snap.effects?.length ? JSON.parse(JSON.stringify(snap.effects)) : [];
2318
+ refreshEffectsUI(sceneName);
2319
+ }
1944
2320
  // Restore overlay type (triggers field re-render)
1945
2321
  const typeEl = card.querySelector('[data-field="overlay-type"]');
1946
2322
  if (typeEl) {
@@ -2000,6 +2376,7 @@ document.getElementById('btn-save').addEventListener('click', async () => {
2000
2376
  try {
2001
2377
  await saveVoiceover();
2002
2378
  await saveOverlays();
2379
+ await saveEffects();
2003
2380
  clearDirty();
2004
2381
  setStatus('All changes saved', 'saved');
2005
2382
  saveBtn.textContent = '\\u2713 Saved';
@@ -2021,22 +2398,32 @@ document.getElementById('btn-rerecord').addEventListener('click', async () => {
2021
2398
  if (isDirty) {
2022
2399
  await saveVoiceover();
2023
2400
  await saveOverlays();
2401
+ await saveEffects();
2024
2402
  clearDirty();
2025
2403
  }
2026
- const btn = document.getElementById('btn-rerecord');
2027
- btn.disabled = true;
2028
- btn.textContent = 'Recording...';
2029
- setStatus('Re-recording pipeline...', 'saving');
2404
+ const overlay = document.getElementById('recording-overlay');
2405
+ const title = document.getElementById('recording-title');
2406
+ const subtitle = document.getElementById('recording-subtitle');
2407
+ overlay.classList.remove('success', 'error');
2408
+ overlay.classList.add('active');
2409
+ title.textContent = 'Re-recording pipeline...';
2410
+ subtitle.textContent = 'All editing is paused while the pipeline runs.';
2411
+ video.pause();
2412
+ stopAudio();
2413
+ showPlayIcon();
2030
2414
  try {
2031
2415
  const resp = await fetch('/api/rerecord', { method: 'POST' });
2032
2416
  const result = await resp.json();
2033
2417
  if (!result.ok) throw new Error(result.error);
2034
- setStatus('Re-record complete! Reloading...', 'saved');
2418
+ overlay.classList.add('success');
2419
+ title.textContent = 'Recording complete!';
2420
+ subtitle.textContent = 'Reloading preview...';
2035
2421
  setTimeout(() => location.reload(), 1500);
2036
2422
  } catch (err) {
2037
- setStatus('Re-record failed: ' + err.message, 'error');
2038
- btn.disabled = false;
2039
- btn.textContent = 'Re-record';
2423
+ overlay.classList.add('error');
2424
+ title.textContent = 'Recording failed';
2425
+ subtitle.textContent = err.message;
2426
+ setTimeout(() => overlay.classList.remove('active', 'error'), 5000);
2040
2427
  }
2041
2428
  });
2042
2429