@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.
- package/README.md +13 -0
- 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 +24 -12
- 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 +38 -1
- 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 +69 -34
- 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 +544 -30
- package/dist/preview.js.map +1 -1
- package/dist/tts/engine.d.ts +12 -0
- package/dist/tts/engine.d.ts.map +1 -1
- package/dist/tts/engine.js +55 -0
- package/dist/tts/engine.js.map +1 -1
- package/dist/tts/engines/index.d.ts +4 -0
- package/dist/tts/engines/index.d.ts.map +1 -1
- package/dist/tts/engines/index.js +3 -0
- package/dist/tts/engines/index.js.map +1 -1
- package/dist/tts/engines/kokoro.d.ts.map +1 -1
- package/dist/tts/engines/kokoro.js +24 -17
- package/dist/tts/engines/kokoro.js.map +1 -1
- package/dist/tts/engines/transformers.d.ts +27 -0
- package/dist/tts/engines/transformers.d.ts.map +1 -0
- package/dist/tts/engines/transformers.js +104 -0
- package/dist/tts/engines/transformers.js.map +1 -0
- package/dist/tts/generate.d.ts.map +1 -1
- package/dist/tts/generate.js +10 -5
- package/dist/tts/generate.js.map +1 -1
- package/dist/validate.js +1 -2
- package/dist/validate.js.map +1 -1
- 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
|
|
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 (
|
|
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 };
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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:
|
|
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
|
|
270
|
-
|
|
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
|
-
|
|
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">▶</button><button class="btn" onclick="pausePreview()" title="Pause">▮▮</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">×</button>
|
|
1845
|
+
<button class="btn btn-sm" onclick="previewEffect('\${esc(sceneName)}', \${index})" title="Preview effect">▶</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
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
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
|
|