@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/camera.d.ts +12 -4
- package/dist/camera.d.ts.map +1 -1
- package/dist/camera.js +98 -20
- package/dist/camera.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -0
- package/dist/cli.js.map +1 -1
- package/dist/cursor.d.ts +24 -0
- package/dist/cursor.d.ts.map +1 -0
- package/dist/cursor.js +110 -0
- package/dist/cursor.js.map +1 -0
- package/dist/export.d.ts +2 -0
- package/dist/export.d.ts.map +1 -1
- package/dist/export.js +10 -4
- package/dist/export.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/overlays/types.d.ts +36 -0
- package/dist/overlays/types.d.ts.map +1 -1
- package/dist/overlays/types.js +6 -0
- package/dist/overlays/types.js.map +1 -1
- package/dist/parse-playwright.d.ts.map +1 -1
- package/dist/parse-playwright.js +5 -3
- package/dist/parse-playwright.js.map +1 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +22 -5
- package/dist/pipeline.js.map +1 -1
- package/dist/preview.d.ts +1 -0
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +410 -23
- package/dist/preview.js.map +1 -1
- package/package.json +1 -1
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
|
|
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 (
|
|
161
|
-
const
|
|
162
|
-
|
|
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
|
|
270
|
-
const
|
|
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">▶</button><button class="btn" onclick="pausePreview()" title="Pause">▮▮</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">×</button>
|
|
1753
|
+
<button class="btn btn-sm" onclick="previewEffect('\${esc(sceneName)}', \${index})" title="Preview effect">▶</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
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
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
|
|