@argo-video/cli 0.22.1 → 0.24.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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +39 -0
- package/dist/cli.js.map +1 -1
- package/dist/export.d.ts +3 -0
- package/dist/export.d.ts.map +1 -1
- package/dist/export.js +29 -5
- package/dist/export.js.map +1 -1
- package/dist/import.d.ts +21 -0
- package/dist/import.d.ts.map +1 -0
- package/dist/import.js +82 -0
- package/dist/import.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/media.d.ts +18 -0
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +90 -1
- package/dist/media.js.map +1 -1
- package/dist/overlays/render-to-png.d.ts +80 -0
- package/dist/overlays/render-to-png.d.ts.map +1 -0
- package/dist/overlays/render-to-png.js +280 -0
- package/dist/overlays/render-to-png.js.map +1 -0
- package/dist/overlays/zones.d.ts.map +1 -1
- package/dist/overlays/zones.js +36 -1
- package/dist/overlays/zones.js.map +1 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +23 -0
- package/dist/pipeline.js.map +1 -1
- package/dist/preview.d.ts.map +1 -1
- package/dist/preview.js +700 -115
- package/dist/preview.js.map +1 -1
- package/package.json +1 -1
package/dist/preview.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* renders overlay cues on a DOM layer, and lets the user edit voiceover text
|
|
8
8
|
* + overlay props inline with per-scene TTS regen.
|
|
9
9
|
*/
|
|
10
|
-
import { execFile } from 'node:child_process';
|
|
10
|
+
import { execFile, spawnSync } from 'node:child_process';
|
|
11
11
|
import { createServer } from 'node:http';
|
|
12
12
|
import { readFileSync, existsSync, readdirSync, writeFileSync, statSync, createReadStream, unlinkSync, mkdirSync } from 'node:fs';
|
|
13
13
|
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
@@ -21,6 +21,8 @@ import { exportVideo, checkFfmpeg } from './export.js';
|
|
|
21
21
|
import { applySpeedRampToTimeline } from './speed-ramp.js';
|
|
22
22
|
import { shiftCameraMoves, scaleCameraMoves } from './camera-move.js';
|
|
23
23
|
import { resolveFreezes, adjustPlacementsForFreezes, totalFreezeDurationMs } from './freeze.js';
|
|
24
|
+
import { buildOverlayPngsForImport, isImportedVideo } from './overlays/render-to-png.js';
|
|
25
|
+
import { detectVideoTheme, getVideoDurationMs } from './media.js';
|
|
24
26
|
const MIME_TYPES = {
|
|
25
27
|
'.html': 'text/html',
|
|
26
28
|
'.js': 'text/javascript',
|
|
@@ -36,6 +38,39 @@ function readJsonFile(filePath, fallback) {
|
|
|
36
38
|
return fallback;
|
|
37
39
|
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
38
40
|
}
|
|
41
|
+
function ensureSeekablePreviewProxy(rawVideoPath, proxyPath) {
|
|
42
|
+
try {
|
|
43
|
+
const needsRender = !existsSync(proxyPath) || statSync(proxyPath).mtimeMs < statSync(rawVideoPath).mtimeMs;
|
|
44
|
+
if (!needsRender)
|
|
45
|
+
return proxyPath;
|
|
46
|
+
checkFfmpeg();
|
|
47
|
+
const result = spawnSync('ffmpeg', [
|
|
48
|
+
'-i', rawVideoPath,
|
|
49
|
+
'-map', '0:v:0',
|
|
50
|
+
'-map', '0:a:0?',
|
|
51
|
+
'-c:v', 'libx264',
|
|
52
|
+
'-preset', 'veryfast',
|
|
53
|
+
'-crf', '23',
|
|
54
|
+
'-pix_fmt', 'yuv420p',
|
|
55
|
+
'-movflags', '+faststart',
|
|
56
|
+
'-c:a', 'aac',
|
|
57
|
+
'-b:a', '128k',
|
|
58
|
+
'-y', proxyPath,
|
|
59
|
+
], {
|
|
60
|
+
stdio: 'pipe',
|
|
61
|
+
});
|
|
62
|
+
if (result.status !== 0) {
|
|
63
|
+
const stderr = result.stderr?.toString('utf-8').trim();
|
|
64
|
+
console.warn(`Warning: failed to create preview MP4 proxy for ${rawVideoPath}${stderr ? `: ${stderr}` : ''}`);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return proxyPath;
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.warn(`Warning: failed to prepare seekable preview video: ${err.message}`);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
39
74
|
function setManifestField(target, key, value) {
|
|
40
75
|
if (value === undefined || value === null || value === '') {
|
|
41
76
|
if (key in target) {
|
|
@@ -98,12 +133,13 @@ function updatePreviewOverlayEntry(target, overlay) {
|
|
|
98
133
|
}
|
|
99
134
|
return changed;
|
|
100
135
|
}
|
|
101
|
-
function buildRenderedOverlays(overlays) {
|
|
136
|
+
function buildRenderedOverlays(overlays, themeMap) {
|
|
102
137
|
const renderedOverlays = {};
|
|
103
138
|
for (const entry of overlays) {
|
|
104
139
|
const { scene, ...cue } = entry;
|
|
105
140
|
const zone = cue.placement ?? 'bottom-center';
|
|
106
|
-
const
|
|
141
|
+
const theme = themeMap?.[scene] ?? 'dark';
|
|
142
|
+
const { contentHtml, styles } = renderTemplate(cue, theme);
|
|
107
143
|
renderedOverlays[scene] = { html: contentHtml, styles, zone };
|
|
108
144
|
}
|
|
109
145
|
return renderedOverlays;
|
|
@@ -177,7 +213,32 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
|
|
|
177
213
|
const reportPath = join(demoDir, 'scene-report.json');
|
|
178
214
|
const persistedReport = readJsonFile(reportPath, null);
|
|
179
215
|
const sceneReport = buildPreviewSceneReport(timing, sceneDurations, persistedReport);
|
|
180
|
-
|
|
216
|
+
// Detect per-scene overlay theme from the video content.
|
|
217
|
+
// Uses ffmpeg to sample frames at each overlay's scene timestamp.
|
|
218
|
+
// Find video file — prefer original extension (imported videos) over .webm
|
|
219
|
+
const videoExts = ['.mp4', '.mov', '.mkv', '.avi', '.webm'];
|
|
220
|
+
let videoPath = null;
|
|
221
|
+
for (const ext of videoExts) {
|
|
222
|
+
const candidate = join(demoDir, `video${ext}`);
|
|
223
|
+
if (existsSync(candidate)) {
|
|
224
|
+
videoPath = candidate;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
let overlayThemeMap;
|
|
229
|
+
if (videoPath && overlays.length > 0) {
|
|
230
|
+
overlayThemeMap = {};
|
|
231
|
+
for (const ov of overlays) {
|
|
232
|
+
const sceneMs = timing[ov.scene] ?? 0;
|
|
233
|
+
// Use next scene start as end bound, or scene + 5s as fallback
|
|
234
|
+
const nextSceneMs = Object.values(timing)
|
|
235
|
+
.filter((ms) => ms > sceneMs)
|
|
236
|
+
.sort((a, b) => a - b)[0];
|
|
237
|
+
const endMs = nextSceneMs ?? sceneMs + 5000;
|
|
238
|
+
overlayThemeMap[ov.scene] = detectVideoTheme(videoPath, sceneMs, endMs);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const renderedOverlays = buildRenderedOverlays(overlays, overlayThemeMap);
|
|
181
242
|
// Pipeline metadata (reuse meta loaded above for headTrimMs)
|
|
182
243
|
const pipelineMeta = Object.keys(meta).length > 0 ? meta : null;
|
|
183
244
|
const hasGenerated = Boolean(activeMusicPath && existsSync(activeMusicPath));
|
|
@@ -188,6 +249,27 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
|
|
|
188
249
|
include: hasGenerated || hasConfig,
|
|
189
250
|
volume: exportConfig?.musicVolume ?? 0.15,
|
|
190
251
|
};
|
|
252
|
+
// Get actual video duration as the timeline floor
|
|
253
|
+
let videoDurationMs = 0;
|
|
254
|
+
const videoExtsForDur = ['.mp4', '.mov', '.mkv', '.avi', '.webm'];
|
|
255
|
+
for (const ext of videoExtsForDur) {
|
|
256
|
+
const candidate = join(demoDir, `video${ext}`);
|
|
257
|
+
if (existsSync(candidate)) {
|
|
258
|
+
try {
|
|
259
|
+
videoDurationMs = getVideoDurationMs(candidate);
|
|
260
|
+
}
|
|
261
|
+
catch { /* ignore */ }
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Also check the exported MP4
|
|
266
|
+
const exportedMp4 = join(outputDir, `${demoName}.mp4`);
|
|
267
|
+
if (videoDurationMs === 0 && existsSync(exportedMp4)) {
|
|
268
|
+
try {
|
|
269
|
+
videoDurationMs = getVideoDurationMs(exportedMp4);
|
|
270
|
+
}
|
|
271
|
+
catch { /* ignore */ }
|
|
272
|
+
}
|
|
191
273
|
return {
|
|
192
274
|
demoName,
|
|
193
275
|
timing,
|
|
@@ -197,6 +279,8 @@ function loadPreviewData(demoName, argoDir, demosDir, outputDir = 'videos', expo
|
|
|
197
279
|
sceneDurations,
|
|
198
280
|
sceneReport,
|
|
199
281
|
renderedOverlays,
|
|
282
|
+
overlayThemes: overlayThemeMap ?? {},
|
|
283
|
+
videoDurationMs,
|
|
200
284
|
pipelineMeta,
|
|
201
285
|
bgm,
|
|
202
286
|
};
|
|
@@ -267,8 +351,8 @@ function refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, defaults) {
|
|
|
267
351
|
};
|
|
268
352
|
const clipPath = cache.getClipPath(demoName, cacheEntry);
|
|
269
353
|
if (!existsSync(clipPath)) {
|
|
270
|
-
|
|
271
|
-
|
|
354
|
+
// Clip not generated yet (e.g., imported video without TTS run) — skip silently
|
|
355
|
+
continue;
|
|
272
356
|
}
|
|
273
357
|
const clipInfo = readClipInfo(clipPath, entry.scene);
|
|
274
358
|
clips.push(clipInfo);
|
|
@@ -319,14 +403,48 @@ export async function startPreviewServer(options) {
|
|
|
319
403
|
const port = options.port ?? 0; // 0 = auto-assign
|
|
320
404
|
const demoName = options.demoName;
|
|
321
405
|
const demoDir = join(argoDir, demoName);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
let
|
|
326
|
-
|
|
327
|
-
|
|
406
|
+
const importedVideo = isImportedVideo(argoDir, demoName);
|
|
407
|
+
// Raw video path in .argo/<demo>/ — used for duration probing/theme detection and
|
|
408
|
+
// as the source for an optional seekable preview proxy.
|
|
409
|
+
let rawVideoPath = null;
|
|
410
|
+
for (const rawExt of ['.mp4', '.mov', '.mkv', '.avi', '.webm']) {
|
|
411
|
+
const candidate = join(demoDir, `video${rawExt}`);
|
|
412
|
+
if (existsSync(candidate)) {
|
|
413
|
+
rawVideoPath = candidate;
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Prefer exported MP4 (has keyframes for seeking), then original-extension import, then raw WebM
|
|
418
|
+
const exportedMp4 = join(outputDir, `${demoName}.mp4`);
|
|
419
|
+
const previewProxyMp4 = join(demoDir, 'preview.mp4');
|
|
420
|
+
const mimeMap = {
|
|
421
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska',
|
|
422
|
+
'.avi': 'video/x-msvideo', '.webm': 'video/webm',
|
|
423
|
+
};
|
|
424
|
+
let videoPath = null;
|
|
425
|
+
if (existsSync(exportedMp4)) {
|
|
426
|
+
videoPath = exportedMp4;
|
|
328
427
|
}
|
|
329
|
-
|
|
428
|
+
else if (importedVideo && rawVideoPath) {
|
|
429
|
+
videoPath = ensureSeekablePreviewProxy(rawVideoPath, previewProxyMp4) ?? rawVideoPath;
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
// Find the original-extension video first (correct MIME), fall back to video.webm
|
|
433
|
+
for (const ext of ['.mp4', '.mov', '.mkv', '.avi', '.webm']) {
|
|
434
|
+
const candidate = join(demoDir, `video${ext}`);
|
|
435
|
+
if (existsSync(candidate)) {
|
|
436
|
+
videoPath = candidate;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (!videoPath || !existsSync(videoPath)) {
|
|
442
|
+
throw new Error(`No recording found for '${demoName}'. Run 'argo pipeline ${demoName}' or 'argo import' first.`);
|
|
443
|
+
}
|
|
444
|
+
const ext = videoPath.slice(videoPath.lastIndexOf('.'));
|
|
445
|
+
let videoMime = mimeMap[ext] ?? 'video/mp4';
|
|
446
|
+
if (!rawVideoPath)
|
|
447
|
+
rawVideoPath = videoPath; // fallback to served video
|
|
330
448
|
// Track BGM saved from the music generator panel
|
|
331
449
|
let activeMusicPath;
|
|
332
450
|
// Check if a previously saved BGM exists
|
|
@@ -363,6 +481,22 @@ export async function startPreviewServer(options) {
|
|
|
363
481
|
if (existing) {
|
|
364
482
|
changed = updatePreviewVoiceoverEntry(existing, vo) || changed;
|
|
365
483
|
}
|
|
484
|
+
else {
|
|
485
|
+
// New scene — always create a manifest entry (even without text)
|
|
486
|
+
const newEntry = { scene: vo.scene };
|
|
487
|
+
if (vo.text?.trim())
|
|
488
|
+
newEntry.text = vo.text;
|
|
489
|
+
if (vo.voice)
|
|
490
|
+
newEntry.voice = vo.voice;
|
|
491
|
+
if (vo.speed)
|
|
492
|
+
newEntry.speed = vo.speed;
|
|
493
|
+
if (vo.lang)
|
|
494
|
+
newEntry.lang = vo.lang;
|
|
495
|
+
if (vo._hint)
|
|
496
|
+
newEntry._hint = vo._hint;
|
|
497
|
+
scenes.push(newEntry);
|
|
498
|
+
changed = true;
|
|
499
|
+
}
|
|
366
500
|
}
|
|
367
501
|
if (changed) {
|
|
368
502
|
writeFileSync(scenesPath, JSON.stringify(scenes, null, 2) + '\n', 'utf-8');
|
|
@@ -377,7 +511,22 @@ export async function startPreviewServer(options) {
|
|
|
377
511
|
for await (const chunk of req)
|
|
378
512
|
chunks.push(chunk);
|
|
379
513
|
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
380
|
-
|
|
514
|
+
// Detect per-scene theme from the video for adaptive overlays
|
|
515
|
+
let liveThemeMap;
|
|
516
|
+
if (rawVideoPath && existsSync(rawVideoPath) && body.length > 0) {
|
|
517
|
+
const timingFile = join(demoDir, '.timing.json');
|
|
518
|
+
const liveTiming = existsSync(timingFile)
|
|
519
|
+
? readJsonFile(timingFile, {}) : {};
|
|
520
|
+
liveThemeMap = {};
|
|
521
|
+
for (const ov of body) {
|
|
522
|
+
const sceneMs = liveTiming[ov.scene] ?? 0;
|
|
523
|
+
const nextMs = Object.values(liveTiming)
|
|
524
|
+
.filter((ms) => ms > sceneMs)
|
|
525
|
+
.sort((a, b) => a - b)[0];
|
|
526
|
+
liveThemeMap[ov.scene] = detectVideoTheme(rawVideoPath, sceneMs, nextMs ?? sceneMs + 5000);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const renderedOverlays = buildRenderedOverlays(body, liveThemeMap);
|
|
381
530
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
382
531
|
res.end(JSON.stringify({ ok: true, renderedOverlays }));
|
|
383
532
|
return;
|
|
@@ -438,6 +587,29 @@ export async function startPreviewServer(options) {
|
|
|
438
587
|
res.end(JSON.stringify({ ok: true, changed }));
|
|
439
588
|
return;
|
|
440
589
|
}
|
|
590
|
+
// Save timing marks to .timing.json
|
|
591
|
+
if (url === '/api/save-timing' && req.method === 'POST') {
|
|
592
|
+
const chunks = [];
|
|
593
|
+
for await (const chunk of req)
|
|
594
|
+
chunks.push(chunk);
|
|
595
|
+
const { timing } = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
596
|
+
const timingPath = join(demoDir, '.timing.json');
|
|
597
|
+
// Read existing timing to merge (preserves any extra keys)
|
|
598
|
+
const existing = readJsonFile(timingPath, {});
|
|
599
|
+
// If the pipeline applied head-trimming, shift new timing marks back to raw timeline
|
|
600
|
+
const metaPath = join(outputDir, `${demoName}.meta.json`);
|
|
601
|
+
const meta = existsSync(metaPath) ? readJsonFile(metaPath, {}) : {};
|
|
602
|
+
const headTrimMs = meta?.export?.headTrimMs ?? 0;
|
|
603
|
+
const rawTiming = {};
|
|
604
|
+
for (const [scene, ms] of Object.entries(timing)) {
|
|
605
|
+
rawTiming[scene] = ms + headTrimMs;
|
|
606
|
+
}
|
|
607
|
+
const merged = { ...existing, ...rawTiming };
|
|
608
|
+
writeFileSync(timingPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
609
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
610
|
+
res.end(JSON.stringify({ ok: true }));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
441
613
|
// Regenerate a single TTS clip
|
|
442
614
|
if (url === '/api/regen-clip' && req.method === 'POST') {
|
|
443
615
|
const chunks = [];
|
|
@@ -489,13 +661,21 @@ export async function startPreviewServer(options) {
|
|
|
489
661
|
chunks.push(chunk);
|
|
490
662
|
const bodyText = Buffer.concat(chunks).toString('utf-8').trim();
|
|
491
663
|
const body = bodyText ? JSON.parse(bodyText) : {};
|
|
664
|
+
// Generate TTS clips for any scenes with text (auto-regen before export)
|
|
665
|
+
const manifestPath = join(demosDir, `${demoName}.scenes.json`);
|
|
666
|
+
try {
|
|
667
|
+
await runPreviewTtsGenerate(manifestPath);
|
|
668
|
+
}
|
|
669
|
+
catch (ttsErr) {
|
|
670
|
+
console.warn(`Warning: TTS generation failed, exporting without voiceover: ${ttsErr.message}`);
|
|
671
|
+
}
|
|
492
672
|
// Refresh aligned audio from current clips + timing
|
|
493
673
|
const refreshed = refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, options.ttsDefaults);
|
|
494
674
|
// Read timing for head-trim + placement computation
|
|
495
675
|
const timing = readJsonFile(join(demoDir, '.timing.json'), {});
|
|
496
676
|
const markTimes = Object.values(timing);
|
|
497
677
|
let headTrimMs = 0;
|
|
498
|
-
if (markTimes.length > 0) {
|
|
678
|
+
if (!importedVideo && markTimes.length > 0) {
|
|
499
679
|
const firstMarkMs = Math.min(...markTimes);
|
|
500
680
|
headTrimMs = Math.max(0, firstMarkMs - 200);
|
|
501
681
|
if (headTrimMs <= 500)
|
|
@@ -508,7 +688,7 @@ export async function startPreviewServer(options) {
|
|
|
508
688
|
// Get video duration
|
|
509
689
|
const { execFileSync } = await import('node:child_process');
|
|
510
690
|
const rawDur = execFileSync('ffprobe', [
|
|
511
|
-
'-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0',
|
|
691
|
+
'-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', rawVideoPath,
|
|
512
692
|
], { encoding: 'utf-8' }).trim();
|
|
513
693
|
const totalDurationMs = Math.round(parseFloat(rawDur) * 1000);
|
|
514
694
|
const shiftedDurationMs = totalDurationMs - headTrimMs;
|
|
@@ -533,7 +713,8 @@ export async function startPreviewServer(options) {
|
|
|
533
713
|
? Math.max(0, Math.min(1, body.musicVolume))
|
|
534
714
|
: (ec?.musicVolume ?? 0.15);
|
|
535
715
|
const exportMusicPath = includeBgm ? (activeMusicPath ?? ec?.musicPath) : undefined;
|
|
536
|
-
const
|
|
716
|
+
const effectiveSpeedRamp = importedVideo ? undefined : ec?.speedRamp;
|
|
717
|
+
const rampResult = applySpeedRampToTimeline(placements, shiftedDurationMs, effectiveSpeedRamp);
|
|
537
718
|
const finalPlacements = rampResult.placements;
|
|
538
719
|
const finalDurationMs = rampResult.totalDurationMs;
|
|
539
720
|
const speedRampSegments = rampResult.segments.length > 0 ? rampResult.segments : undefined;
|
|
@@ -583,6 +764,16 @@ export async function startPreviewServer(options) {
|
|
|
583
764
|
}
|
|
584
765
|
}
|
|
585
766
|
catch { /* optional */ }
|
|
767
|
+
// Render overlay PNGs for imported videos (no Playwright recording step).
|
|
768
|
+
const overlayPngs = await buildOverlayPngsForImport({
|
|
769
|
+
argoDir,
|
|
770
|
+
demoName,
|
|
771
|
+
manifestPath: scenesPath,
|
|
772
|
+
placements: freezeAdjustedPlacements,
|
|
773
|
+
videoWidth: ec?.outputWidth ?? 1920,
|
|
774
|
+
videoHeight: ec?.outputHeight ?? 1080,
|
|
775
|
+
deviceScaleFactor: ec?.deviceScaleFactor,
|
|
776
|
+
});
|
|
586
777
|
// Export — use full config so output matches argo pipeline
|
|
587
778
|
await exportVideo({
|
|
588
779
|
demoName,
|
|
@@ -608,10 +799,12 @@ export async function startPreviewServer(options) {
|
|
|
608
799
|
cameraMoves,
|
|
609
800
|
watermark: ec?.watermark,
|
|
610
801
|
freezeSpecs: previewResolvedFreezes.length > 0 ? previewResolvedFreezes : undefined,
|
|
802
|
+
overlayPngs,
|
|
611
803
|
});
|
|
612
804
|
// Switch to serving the new MP4
|
|
613
|
-
|
|
614
|
-
|
|
805
|
+
const newMp4 = join(outputDir, `${demoName}.mp4`);
|
|
806
|
+
if (existsSync(newMp4)) {
|
|
807
|
+
videoPath = newMp4;
|
|
615
808
|
videoMime = 'video/mp4';
|
|
616
809
|
}
|
|
617
810
|
res.end(JSON.stringify({ ok: true }));
|
|
@@ -1093,6 +1286,50 @@ const PREVIEW_HTML = `<!DOCTYPE html>
|
|
|
1093
1286
|
.overlay-cue[data-zone="bottom-right"] { bottom: 60px; right: 40px; }
|
|
1094
1287
|
.overlay-cue[data-zone="center"] { top: 50%; left: 50%; transform: translate(-50%, -50%); }
|
|
1095
1288
|
|
|
1289
|
+
/* Drag-to-snap overlay positioning */
|
|
1290
|
+
.overlay-cue.overlay-draggable {
|
|
1291
|
+
cursor: grab;
|
|
1292
|
+
user-select: none;
|
|
1293
|
+
/* pointer-events only on visible overlays — invisible ones must not intercept clicks */
|
|
1294
|
+
}
|
|
1295
|
+
.overlay-cue.overlay-draggable.visible {
|
|
1296
|
+
pointer-events: auto;
|
|
1297
|
+
}
|
|
1298
|
+
.overlay-cue.overlay-draggable:active {
|
|
1299
|
+
cursor: grabbing;
|
|
1300
|
+
}
|
|
1301
|
+
.overlay-cue.overlay-dragging {
|
|
1302
|
+
cursor: grabbing;
|
|
1303
|
+
opacity: 0.85;
|
|
1304
|
+
z-index: 20;
|
|
1305
|
+
}
|
|
1306
|
+
.snap-zone {
|
|
1307
|
+
position: absolute;
|
|
1308
|
+
border: 2px dashed rgba(99, 102, 241, 0.4);
|
|
1309
|
+
border-radius: 8px;
|
|
1310
|
+
pointer-events: none;
|
|
1311
|
+
opacity: 0;
|
|
1312
|
+
transition: opacity 0.2s;
|
|
1313
|
+
z-index: 15;
|
|
1314
|
+
}
|
|
1315
|
+
.snap-zone.visible {
|
|
1316
|
+
opacity: 1;
|
|
1317
|
+
}
|
|
1318
|
+
.snap-zone.highlight {
|
|
1319
|
+
background: rgba(99, 102, 241, 0.15);
|
|
1320
|
+
border-color: rgba(99, 102, 241, 0.8);
|
|
1321
|
+
}
|
|
1322
|
+
.snap-zone-label {
|
|
1323
|
+
position: absolute;
|
|
1324
|
+
bottom: 4px;
|
|
1325
|
+
left: 50%;
|
|
1326
|
+
transform: translateX(-50%);
|
|
1327
|
+
font-size: 11px;
|
|
1328
|
+
color: rgba(99, 102, 241, 0.8);
|
|
1329
|
+
font-family: var(--mono);
|
|
1330
|
+
white-space: nowrap;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1096
1333
|
/* Timeline bar */
|
|
1097
1334
|
.timeline {
|
|
1098
1335
|
background: var(--surface);
|
|
@@ -1433,6 +1670,24 @@ const PREVIEW_HTML = `<!DOCTYPE html>
|
|
|
1433
1670
|
border-radius: 4px;
|
|
1434
1671
|
}
|
|
1435
1672
|
|
|
1673
|
+
.add-scene-btn {
|
|
1674
|
+
width: 100%;
|
|
1675
|
+
padding: 10px;
|
|
1676
|
+
margin-bottom: 12px;
|
|
1677
|
+
background: var(--bg-card, var(--surface));
|
|
1678
|
+
border: 2px dashed var(--border);
|
|
1679
|
+
border-radius: 10px;
|
|
1680
|
+
color: var(--text-muted);
|
|
1681
|
+
cursor: pointer;
|
|
1682
|
+
font-family: var(--mono);
|
|
1683
|
+
font-size: 0.85rem;
|
|
1684
|
+
transition: all 0.2s;
|
|
1685
|
+
}
|
|
1686
|
+
.add-scene-btn:hover {
|
|
1687
|
+
border-color: var(--accent);
|
|
1688
|
+
color: var(--accent);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1436
1691
|
/* Editable fields */
|
|
1437
1692
|
.field-group { margin-top: 8px; }
|
|
1438
1693
|
.field-group label {
|
|
@@ -1697,6 +1952,12 @@ const PREVIEW_HTML = `<!DOCTYPE html>
|
|
|
1697
1952
|
<div class="video-container">
|
|
1698
1953
|
<video id="video" src="/video" preload="auto" muted playsinline></video>
|
|
1699
1954
|
<div class="overlay-layer" id="overlay-layer"></div>
|
|
1955
|
+
<div class="snap-zone" data-zone="top-left" style="top:10%;left:5%;width:35%;height:35%"><span class="snap-zone-label">top-left</span></div>
|
|
1956
|
+
<div class="snap-zone" data-zone="top-right" style="top:10%;right:5%;width:35%;height:35%"><span class="snap-zone-label">top-right</span></div>
|
|
1957
|
+
<div class="snap-zone" data-zone="bottom-left" style="bottom:10%;left:5%;width:35%;height:35%"><span class="snap-zone-label">bottom-left</span></div>
|
|
1958
|
+
<div class="snap-zone" data-zone="bottom-right" style="bottom:10%;right:5%;width:35%;height:35%"><span class="snap-zone-label">bottom-right</span></div>
|
|
1959
|
+
<div class="snap-zone" data-zone="bottom-center" style="bottom:5%;left:25%;width:50%;height:20%"><span class="snap-zone-label">bottom-center</span></div>
|
|
1960
|
+
<div class="snap-zone" data-zone="center" style="top:30%;left:25%;width:50%;height:40%"><span class="snap-zone-label">center</span></div>
|
|
1700
1961
|
</div>
|
|
1701
1962
|
|
|
1702
1963
|
<div class="timeline">
|
|
@@ -1737,6 +1998,7 @@ const PREVIEW_HTML = `<!DOCTYPE html>
|
|
|
1737
1998
|
<button class="sidebar-tab" data-tab="metadata">Metadata</button>
|
|
1738
1999
|
</div>
|
|
1739
2000
|
<div class="sidebar-panel" id="panel-scenes">
|
|
2001
|
+
<button id="add-scene-btn" class="add-scene-btn">+ Add scene at current time</button>
|
|
1740
2002
|
<div id="scene-list"></div>
|
|
1741
2003
|
<div class="music-panel" id="music-panel">
|
|
1742
2004
|
<div class="music-panel-header" id="music-panel-header">
|
|
@@ -1839,12 +2101,19 @@ const scenes = Object.entries(DATA.timing)
|
|
|
1839
2101
|
|
|
1840
2102
|
let activeScene = null;
|
|
1841
2103
|
|
|
1842
|
-
|
|
1843
|
-
video.
|
|
1844
|
-
|
|
1845
|
-
|
|
2104
|
+
function getPreviewDurationMs() {
|
|
2105
|
+
const mediaDurationMs = Number.isFinite(video.duration) && video.duration > 0
|
|
2106
|
+
? video.duration * 1000
|
|
2107
|
+
: 0;
|
|
2108
|
+
return mediaDurationMs || DATA.videoDurationMs || DATA.sceneReport?.totalDurationMs || 0;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
function renderTimelineMarkers() {
|
|
2112
|
+
const totalMs = getPreviewDurationMs();
|
|
2113
|
+
if (!totalMs) return;
|
|
2114
|
+
|
|
2115
|
+
timelineBar.querySelectorAll('.timeline-scene').forEach(node => node.remove());
|
|
1846
2116
|
|
|
1847
|
-
// Render scene markers on timeline
|
|
1848
2117
|
scenes.forEach((s, i) => {
|
|
1849
2118
|
const pct = (s.startMs / totalMs) * 100;
|
|
1850
2119
|
const nextStart = i + 1 < scenes.length ? scenes[i + 1].startMs : totalMs;
|
|
@@ -1854,23 +2123,38 @@ video.addEventListener('loadedmetadata', () => {
|
|
|
1854
2123
|
marker.className = 'timeline-scene';
|
|
1855
2124
|
marker.style.left = pct + '%';
|
|
1856
2125
|
marker.style.width = Math.max(widthPct, 2) + '%';
|
|
1857
|
-
const hasOverlay =
|
|
1858
|
-
// s.name is already escaped via esc() — safe for innerHTML
|
|
2126
|
+
const hasOverlay = s.overlay?.type;
|
|
1859
2127
|
marker.innerHTML = esc(s.name) + (hasOverlay ? '<span class="has-overlay"></span>' : '');
|
|
1860
2128
|
marker.dataset.scene = s.name;
|
|
1861
2129
|
marker.addEventListener('click', (e) => {
|
|
1862
2130
|
e.stopPropagation();
|
|
2131
|
+
// Don't seek to scene start if user just finished scrubbing
|
|
2132
|
+
if (justScrubbed) return;
|
|
1863
2133
|
seekToScene(s);
|
|
1864
2134
|
});
|
|
1865
2135
|
timelineBar.appendChild(marker);
|
|
1866
2136
|
});
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// ─── Timeline ──────────────────────────────────────────────────────────────
|
|
2140
|
+
video.addEventListener('loadedmetadata', () => {
|
|
2141
|
+
const totalMs = getPreviewDurationMs();
|
|
2142
|
+
document.getElementById('time-total').textContent = formatTime(totalMs);
|
|
2143
|
+
renderTimelineMarkers();
|
|
1867
2144
|
|
|
1868
2145
|
// Create overlay DOM elements
|
|
1869
2146
|
renderOverlayElements();
|
|
1870
2147
|
});
|
|
1871
2148
|
|
|
2149
|
+
if (getPreviewDurationMs() > 0) {
|
|
2150
|
+
document.getElementById('time-total').textContent = formatTime(getPreviewDurationMs());
|
|
2151
|
+
renderTimelineMarkers();
|
|
2152
|
+
}
|
|
2153
|
+
|
|
1872
2154
|
video.addEventListener('timeupdate', () => {
|
|
1873
|
-
|
|
2155
|
+
// Don't update UI during scrubbing — scrubToX handles it directly
|
|
2156
|
+
if (isScrubbing) return;
|
|
2157
|
+
const totalMs = getPreviewDurationMs();
|
|
1874
2158
|
const currentMs = video.currentTime * 1000;
|
|
1875
2159
|
if (scenePlaybackEndMs !== null && currentMs >= scenePlaybackEndMs) {
|
|
1876
2160
|
const stopAt = scenePlaybackEndMs;
|
|
@@ -1899,13 +2183,47 @@ video.addEventListener('timeupdate', () => {
|
|
|
1899
2183
|
}
|
|
1900
2184
|
});
|
|
1901
2185
|
|
|
1902
|
-
// Click on timeline bar to
|
|
1903
|
-
|
|
2186
|
+
// Click and drag on timeline bar to scrub
|
|
2187
|
+
let isScrubbing = false;
|
|
2188
|
+
let justScrubbed = false;
|
|
2189
|
+
|
|
2190
|
+
function scrubToX(clientX) {
|
|
1904
2191
|
const rect = timelineBar.getBoundingClientRect();
|
|
1905
|
-
const pct = (
|
|
1906
|
-
const
|
|
2192
|
+
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
2193
|
+
const dur = video.duration;
|
|
2194
|
+
if (!dur || !Number.isFinite(dur)) return;
|
|
2195
|
+
const targetSec = pct * dur;
|
|
1907
2196
|
scenePlaybackEndMs = null;
|
|
1908
|
-
|
|
2197
|
+
// Direct assignment — no async seek during scrubbing
|
|
2198
|
+
video.currentTime = targetSec;
|
|
2199
|
+
// Update UI immediately
|
|
2200
|
+
const ms = targetSec * 1000;
|
|
2201
|
+
document.getElementById('time-current').textContent = formatTime(ms);
|
|
2202
|
+
timelineProgress.style.width = (pct * 100) + '%';
|
|
2203
|
+
document.getElementById('timeline-playhead').style.left = (pct * 100) + '%';
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
timelineBar.addEventListener('mousedown', (e) => {
|
|
2207
|
+
isScrubbing = true;
|
|
2208
|
+
video.pause();
|
|
2209
|
+
showPlayIcon();
|
|
2210
|
+
scrubToX(e.clientX);
|
|
2211
|
+
e.preventDefault();
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
document.addEventListener('mousemove', (e) => {
|
|
2215
|
+
if (!isScrubbing) return;
|
|
2216
|
+
scrubToX(e.clientX);
|
|
2217
|
+
e.preventDefault();
|
|
2218
|
+
});
|
|
2219
|
+
|
|
2220
|
+
document.addEventListener('mouseup', () => {
|
|
2221
|
+
if (isScrubbing) {
|
|
2222
|
+
isScrubbing = false;
|
|
2223
|
+
// Prevent the subsequent click event on scene markers from seeking to scene start
|
|
2224
|
+
justScrubbed = true;
|
|
2225
|
+
setTimeout(() => { justScrubbed = false; }, 50);
|
|
2226
|
+
}
|
|
1909
2227
|
});
|
|
1910
2228
|
|
|
1911
2229
|
// Play/pause icon toggling
|
|
@@ -2005,6 +2323,7 @@ function renderOverlayElements() {
|
|
|
2005
2323
|
el.innerHTML = '<span class="preview-badge">PREVIEW</span>' + s.rendered.html;
|
|
2006
2324
|
Object.assign(el.style, s.rendered.styles);
|
|
2007
2325
|
overlayLayer.appendChild(el);
|
|
2326
|
+
makeOverlayDraggable(el);
|
|
2008
2327
|
}
|
|
2009
2328
|
}
|
|
2010
2329
|
|
|
@@ -2018,9 +2337,13 @@ function updateOverlayVisibility(currentMs) {
|
|
|
2018
2337
|
const el = overlayLayer.querySelector('[data-scene="' + s.name + '"]');
|
|
2019
2338
|
if (!el) continue;
|
|
2020
2339
|
|
|
2021
|
-
// Show overlay
|
|
2340
|
+
// Show overlay during this scene's time range.
|
|
2341
|
+
// For scenes without TTS (endMs === startMs), extend to the next scene's start or video end.
|
|
2022
2342
|
const { startMs, endMs } = getSceneBounds(s);
|
|
2023
|
-
const
|
|
2343
|
+
const sceneIdx = scenes.indexOf(s);
|
|
2344
|
+
const nextStart = sceneIdx + 1 < scenes.length ? scenes[sceneIdx + 1].startMs : getPreviewDurationMs();
|
|
2345
|
+
const effectiveEnd = endMs > startMs ? endMs : nextStart;
|
|
2346
|
+
const isActive = currentMs >= startMs && currentMs < effectiveEnd;
|
|
2024
2347
|
el.classList.toggle('visible', isActive);
|
|
2025
2348
|
}
|
|
2026
2349
|
}
|
|
@@ -2029,6 +2352,154 @@ document.getElementById('cb-overlays').addEventListener('change', () => {
|
|
|
2029
2352
|
updateOverlayVisibility(video.currentTime * 1000);
|
|
2030
2353
|
});
|
|
2031
2354
|
|
|
2355
|
+
// ─── Drag-to-snap overlay positioning ──────────────────────────────────────
|
|
2356
|
+
const SNAP_ZONES = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'bottom-center', 'center'];
|
|
2357
|
+
const snapZoneEls = document.querySelectorAll('.snap-zone');
|
|
2358
|
+
|
|
2359
|
+
// Zone center positions as fractions of the container
|
|
2360
|
+
const ZONE_CENTERS = {
|
|
2361
|
+
'top-left': { x: 0.05 + 0.35 / 2, y: 0.10 + 0.35 / 2 },
|
|
2362
|
+
'top-right': { x: 1 - 0.05 - 0.35 / 2, y: 0.10 + 0.35 / 2 },
|
|
2363
|
+
'bottom-left': { x: 0.05 + 0.35 / 2, y: 1 - 0.10 - 0.35 / 2 },
|
|
2364
|
+
'bottom-right': { x: 1 - 0.05 - 0.35 / 2, y: 1 - 0.10 - 0.35 / 2 },
|
|
2365
|
+
'bottom-center': { x: 0.25 + 0.50 / 2, y: 1 - 0.05 - 0.20 / 2 },
|
|
2366
|
+
'center': { x: 0.25 + 0.50 / 2, y: 0.30 + 0.40 / 2 },
|
|
2367
|
+
};
|
|
2368
|
+
|
|
2369
|
+
let dragState = null;
|
|
2370
|
+
let isOverlayDragging = false;
|
|
2371
|
+
|
|
2372
|
+
function showSnapZones() {
|
|
2373
|
+
snapZoneEls.forEach(el => el.classList.add('visible'));
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
function hideSnapZones() {
|
|
2377
|
+
snapZoneEls.forEach(el => {
|
|
2378
|
+
el.classList.remove('visible', 'highlight');
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
function highlightNearestZone(fracX, fracY) {
|
|
2383
|
+
let nearest = null;
|
|
2384
|
+
let minDist = Infinity;
|
|
2385
|
+
for (const zone of SNAP_ZONES) {
|
|
2386
|
+
const c = ZONE_CENTERS[zone];
|
|
2387
|
+
const d = Math.hypot(fracX - c.x, fracY - c.y);
|
|
2388
|
+
if (d < minDist) {
|
|
2389
|
+
minDist = d;
|
|
2390
|
+
nearest = zone;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
snapZoneEls.forEach(el => {
|
|
2394
|
+
el.classList.toggle('highlight', el.dataset.zone === nearest);
|
|
2395
|
+
});
|
|
2396
|
+
return nearest;
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
function makeOverlayDraggable(el) {
|
|
2400
|
+
el.classList.add('overlay-draggable');
|
|
2401
|
+
|
|
2402
|
+
el.addEventListener('mousedown', (e) => {
|
|
2403
|
+
// Only primary button
|
|
2404
|
+
if (e.button !== 0) return;
|
|
2405
|
+
e.preventDefault();
|
|
2406
|
+
e.stopPropagation();
|
|
2407
|
+
|
|
2408
|
+
const container = el.closest('.video-container');
|
|
2409
|
+
if (!container) return;
|
|
2410
|
+
const containerRect = container.getBoundingClientRect();
|
|
2411
|
+
const elRect = el.getBoundingClientRect();
|
|
2412
|
+
|
|
2413
|
+
// Store original zone positioning styles so we can restore if needed
|
|
2414
|
+
const sceneName = el.dataset.scene;
|
|
2415
|
+
|
|
2416
|
+
dragState = {
|
|
2417
|
+
el,
|
|
2418
|
+
sceneName,
|
|
2419
|
+
container,
|
|
2420
|
+
containerRect,
|
|
2421
|
+
// Offset from mouse to element top-left
|
|
2422
|
+
offsetX: e.clientX - elRect.left,
|
|
2423
|
+
offsetY: e.clientY - elRect.top,
|
|
2424
|
+
nearestZone: null,
|
|
2425
|
+
};
|
|
2426
|
+
|
|
2427
|
+
// Switch to fixed positioning for free drag
|
|
2428
|
+
isOverlayDragging = true;
|
|
2429
|
+
el.classList.add('overlay-dragging');
|
|
2430
|
+
el.style.position = 'absolute';
|
|
2431
|
+
el.style.left = (elRect.left - containerRect.left) + 'px';
|
|
2432
|
+
el.style.top = (elRect.top - containerRect.top) + 'px';
|
|
2433
|
+
el.style.right = 'auto';
|
|
2434
|
+
el.style.bottom = 'auto';
|
|
2435
|
+
el.style.transform = 'none';
|
|
2436
|
+
|
|
2437
|
+
showSnapZones();
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
document.addEventListener('mousemove', (e) => {
|
|
2442
|
+
if (!dragState) return;
|
|
2443
|
+
e.preventDefault();
|
|
2444
|
+
|
|
2445
|
+
const { el, container, containerRect, offsetX, offsetY } = dragState;
|
|
2446
|
+
const rect = containerRect;
|
|
2447
|
+
|
|
2448
|
+
const newLeft = e.clientX - rect.left - offsetX;
|
|
2449
|
+
const newTop = e.clientY - rect.top - offsetY;
|
|
2450
|
+
|
|
2451
|
+
el.style.left = newLeft + 'px';
|
|
2452
|
+
el.style.top = newTop + 'px';
|
|
2453
|
+
|
|
2454
|
+
// Calculate overlay center as fraction of container
|
|
2455
|
+
const elRect = el.getBoundingClientRect();
|
|
2456
|
+
const centerX = (elRect.left + elRect.width / 2 - rect.left) / rect.width;
|
|
2457
|
+
const centerY = (elRect.top + elRect.height / 2 - rect.top) / rect.height;
|
|
2458
|
+
|
|
2459
|
+
dragState.nearestZone = highlightNearestZone(centerX, centerY);
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
document.addEventListener('mouseup', (e) => {
|
|
2463
|
+
if (!dragState) return;
|
|
2464
|
+
|
|
2465
|
+
const { el, sceneName, nearestZone } = dragState;
|
|
2466
|
+
const zone = nearestZone || 'bottom-center';
|
|
2467
|
+
|
|
2468
|
+
// Remove drag styles — let CSS zone positioning take over
|
|
2469
|
+
el.classList.remove('overlay-dragging');
|
|
2470
|
+
el.style.position = '';
|
|
2471
|
+
el.style.left = '';
|
|
2472
|
+
el.style.top = '';
|
|
2473
|
+
el.style.right = '';
|
|
2474
|
+
el.style.bottom = '';
|
|
2475
|
+
el.style.transform = '';
|
|
2476
|
+
|
|
2477
|
+
// Update the data-zone attribute so CSS positioning applies
|
|
2478
|
+
el.dataset.zone = zone;
|
|
2479
|
+
|
|
2480
|
+
// Update s.overlay (single source of truth)
|
|
2481
|
+
const s = scenes.find(sc => sc.name === sceneName);
|
|
2482
|
+
if (s && s.overlay) {
|
|
2483
|
+
s.overlay.placement = zone;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// Update the placement dropdown for visual consistency
|
|
2487
|
+
const placeEl = document.querySelector('select[data-scene="' + sceneName + '"][data-field="overlay-placement"]');
|
|
2488
|
+
if (placeEl) {
|
|
2489
|
+
placeEl.value = zone;
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
// Update rendered data so renderOverlayElements stays in sync
|
|
2493
|
+
if (DATA.renderedOverlays[sceneName]) {
|
|
2494
|
+
DATA.renderedOverlays[sceneName].zone = zone;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
hideSnapZones();
|
|
2498
|
+
markDirty();
|
|
2499
|
+
isOverlayDragging = false;
|
|
2500
|
+
dragState = null;
|
|
2501
|
+
});
|
|
2502
|
+
|
|
2032
2503
|
// ─── Scene list (sidebar) ──────────────────────────────────────────────────
|
|
2033
2504
|
function renderSceneList() {
|
|
2034
2505
|
sceneList.innerHTML = '';
|
|
@@ -2122,8 +2593,66 @@ function renderSceneList() {
|
|
|
2122
2593
|
wireOverlayListeners(s.name);
|
|
2123
2594
|
wireEffectListeners(s.name);
|
|
2124
2595
|
}
|
|
2596
|
+
|
|
2597
|
+
// Trigger overlay preview after rendering so existing overlays appear on the video
|
|
2598
|
+
previewOverlays();
|
|
2125
2599
|
}
|
|
2126
2600
|
|
|
2601
|
+
// ─── Add Scene button ────────────────────────────────────────────────────
|
|
2602
|
+
document.getElementById('add-scene-btn').addEventListener('click', () => {
|
|
2603
|
+
// Generate a unique scene name
|
|
2604
|
+
let idx = scenes.length + 1;
|
|
2605
|
+
let name = 'scene-' + idx;
|
|
2606
|
+
const existingNames = new Set(scenes.map(s => s.name));
|
|
2607
|
+
while (existingNames.has(name)) {
|
|
2608
|
+
idx++;
|
|
2609
|
+
name = 'scene-' + idx;
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// Timestamp from current video position
|
|
2613
|
+
const startMs = Math.round(video.currentTime * 1000);
|
|
2614
|
+
|
|
2615
|
+
// Add to timing data
|
|
2616
|
+
DATA.timing[name] = startMs;
|
|
2617
|
+
DATA.sceneDurations[name] = 0;
|
|
2618
|
+
DATA.voiceover.push({ scene: name, text: '' });
|
|
2619
|
+
|
|
2620
|
+
// Insert scene into the sorted array at the right position
|
|
2621
|
+
const newScene = {
|
|
2622
|
+
name,
|
|
2623
|
+
startMs,
|
|
2624
|
+
vo: { scene: name, text: '' },
|
|
2625
|
+
overlay: undefined,
|
|
2626
|
+
effects: [],
|
|
2627
|
+
rendered: undefined,
|
|
2628
|
+
report: undefined,
|
|
2629
|
+
};
|
|
2630
|
+
// Find insertion index to keep sorted by startMs
|
|
2631
|
+
let insertIdx = scenes.length;
|
|
2632
|
+
for (let i = 0; i < scenes.length; i++) {
|
|
2633
|
+
if (scenes[i].startMs > startMs) {
|
|
2634
|
+
insertIdx = i;
|
|
2635
|
+
break;
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
scenes.splice(insertIdx, 0, newScene);
|
|
2639
|
+
|
|
2640
|
+
// Capture current form values before re-render wipes the DOM
|
|
2641
|
+
syncFormValuesToScenes();
|
|
2642
|
+
|
|
2643
|
+
// Re-render scene list
|
|
2644
|
+
renderSceneList();
|
|
2645
|
+
snapshotAllScenes();
|
|
2646
|
+
markDirty();
|
|
2647
|
+
|
|
2648
|
+
// Auto-scroll to the new card and expand it
|
|
2649
|
+
const newCard = document.querySelector('.scene-card[data-scene="' + name + '"]');
|
|
2650
|
+
if (newCard) {
|
|
2651
|
+
newCard.classList.add('expanded');
|
|
2652
|
+
newCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2653
|
+
}
|
|
2654
|
+
});
|
|
2655
|
+
|
|
2127
2656
|
function renderDynamicOverlayFields(sceneName, type, ov) {
|
|
2128
2657
|
if (!type) return '';
|
|
2129
2658
|
let fields = '';
|
|
@@ -2192,6 +2721,13 @@ function renderOverlayFields(s) {
|
|
|
2192
2721
|
<option value="bottom-right" \${ov?.placement === 'bottom-right' ? 'selected' : ''}>bottom-right</option>
|
|
2193
2722
|
<option value="center" \${ov?.placement === 'center' ? 'selected' : ''}>center</option>
|
|
2194
2723
|
</select>
|
|
2724
|
+
</div>
|
|
2725
|
+
<div style="flex:0 0 auto; display:flex; align-items:flex-end; padding-bottom:2px;">
|
|
2726
|
+
<span class="overlay-theme-badge" data-scene="\${esc(s.name)}" title="Auto-detected overlay theme"
|
|
2727
|
+
style="font-size:11px; padding:2px 6px; border-radius:3px;
|
|
2728
|
+
background:\${(DATA.overlayThemes[s.name] ?? 'dark') === 'light' ? '#fff' : '#333'};
|
|
2729
|
+
color:\${(DATA.overlayThemes[s.name] ?? 'dark') === 'light' ? '#333' : '#ccc'};
|
|
2730
|
+
border:1px solid #555;">\${DATA.overlayThemes[s.name] ?? 'dark'}</span>
|
|
2195
2731
|
</div>\` : ''}
|
|
2196
2732
|
</div>
|
|
2197
2733
|
\${type ? \`<div class="field-group">
|
|
@@ -2213,6 +2749,10 @@ function updateOverlayFieldsForScene(sceneName) {
|
|
|
2213
2749
|
const typeEl = document.querySelector('select[data-scene="' + sceneName + '"][data-field="overlay-type"]');
|
|
2214
2750
|
const type = typeEl?.value ?? '';
|
|
2215
2751
|
const s = scenes.find(sc => sc.name === sceneName);
|
|
2752
|
+
if (s) {
|
|
2753
|
+
if (!type) s.overlay = undefined;
|
|
2754
|
+
else s.overlay = { ...(s.overlay ?? {}), type };
|
|
2755
|
+
}
|
|
2216
2756
|
const ov = s?.overlay;
|
|
2217
2757
|
const container = document.querySelector('.overlay-fields-dynamic[data-scene="' + sceneName + '"]');
|
|
2218
2758
|
if (!container) return;
|
|
@@ -2227,6 +2767,8 @@ function updateOverlayFieldsForScene(sceneName) {
|
|
|
2227
2767
|
section.outerHTML = renderOverlayFields(fakeScene);
|
|
2228
2768
|
// Re-wire event listeners for the new overlay fields
|
|
2229
2769
|
wireOverlayListeners(sceneName);
|
|
2770
|
+
// Trigger preview to show the overlay immediately after type change
|
|
2771
|
+
renderSingleSceneOverlay(sceneName);
|
|
2230
2772
|
}
|
|
2231
2773
|
}
|
|
2232
2774
|
|
|
@@ -2234,19 +2776,80 @@ function wireOverlayListeners(sceneName) {
|
|
|
2234
2776
|
const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
|
|
2235
2777
|
if (!card) return;
|
|
2236
2778
|
let debounceTimer;
|
|
2237
|
-
|
|
2779
|
+
|
|
2780
|
+
// Type change — special: re-renders the dynamic fields
|
|
2781
|
+
const typeSelect = card.querySelector('select[data-field="overlay-type"]');
|
|
2782
|
+
if (typeSelect) {
|
|
2783
|
+
typeSelect.addEventListener('change', () => {
|
|
2784
|
+
const s = scenes.find(sc => sc.name === sceneName);
|
|
2785
|
+
if (!s) return;
|
|
2786
|
+
const type = typeSelect.value;
|
|
2787
|
+
if (!type) {
|
|
2788
|
+
s.overlay = undefined;
|
|
2789
|
+
} else {
|
|
2790
|
+
s.overlay = { ...(s.overlay ?? {}), type };
|
|
2791
|
+
}
|
|
2792
|
+
updateOverlayFieldsForScene(sceneName);
|
|
2793
|
+
});
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
// All other overlay fields — update s.overlay directly
|
|
2797
|
+
card.querySelectorAll('[data-field^="overlay-"]').forEach(input => {
|
|
2798
|
+
const field = input.dataset.field;
|
|
2799
|
+
if (field === 'overlay-type') return; // handled above
|
|
2800
|
+
|
|
2238
2801
|
const handler = () => {
|
|
2802
|
+
const s = scenes.find(sc => sc.name === sceneName);
|
|
2803
|
+
if (!s || !s.overlay) return;
|
|
2804
|
+
|
|
2805
|
+
// Map field name to overlay property
|
|
2806
|
+
if (field === 'overlay-placement') s.overlay.placement = input.value;
|
|
2807
|
+
else if (field === 'overlay-motion') {
|
|
2808
|
+
if (input.value && input.value !== 'none') s.overlay.motion = input.value;
|
|
2809
|
+
else delete s.overlay.motion;
|
|
2810
|
+
}
|
|
2811
|
+
else if (field === 'overlay-text') {
|
|
2812
|
+
if (s.overlay.type === 'lower-third' || s.overlay.type === 'callout') {
|
|
2813
|
+
s.overlay.text = input.value;
|
|
2814
|
+
} else {
|
|
2815
|
+
s.overlay.title = input.value;
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
else if (field === 'overlay-body') s.overlay.body = input.value || undefined;
|
|
2819
|
+
else if (field === 'overlay-kicker') s.overlay.kicker = input.value || undefined;
|
|
2820
|
+
else if (field === 'overlay-src') s.overlay.src = input.value || undefined;
|
|
2821
|
+
|
|
2239
2822
|
markDirty();
|
|
2823
|
+
|
|
2824
|
+
// Skip render during drag
|
|
2825
|
+
if (isOverlayDragging) return;
|
|
2826
|
+
|
|
2827
|
+
// Debounce per-scene render
|
|
2240
2828
|
clearTimeout(debounceTimer);
|
|
2241
|
-
debounceTimer = setTimeout(() =>
|
|
2829
|
+
debounceTimer = setTimeout(() => renderSingleSceneOverlay(sceneName), 300);
|
|
2242
2830
|
};
|
|
2831
|
+
|
|
2243
2832
|
input.addEventListener('input', handler);
|
|
2244
2833
|
input.addEventListener('change', handler);
|
|
2245
2834
|
});
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
async function renderSingleSceneOverlay(sceneName) {
|
|
2838
|
+
const s = scenes.find(sc => sc.name === sceneName);
|
|
2839
|
+
if (!s?.overlay?.type) return;
|
|
2840
|
+
|
|
2841
|
+
const ov = [{ ...s.overlay, scene: sceneName }];
|
|
2842
|
+
const resp = await fetch('/api/render-overlays', {
|
|
2843
|
+
method: 'POST',
|
|
2844
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2845
|
+
body: JSON.stringify(ov),
|
|
2846
|
+
});
|
|
2847
|
+
const result = await resp.json();
|
|
2848
|
+
if (result.renderedOverlays?.[sceneName]) {
|
|
2849
|
+
DATA.renderedOverlays[sceneName] = result.renderedOverlays[sceneName];
|
|
2850
|
+
s.rendered = result.renderedOverlays[sceneName];
|
|
2851
|
+
renderOverlayElements();
|
|
2852
|
+
updateOverlayVisibility(video.currentTime * 1000);
|
|
2250
2853
|
}
|
|
2251
2854
|
}
|
|
2252
2855
|
|
|
@@ -2419,6 +3022,18 @@ async function saveEffects() {
|
|
|
2419
3022
|
});
|
|
2420
3023
|
}
|
|
2421
3024
|
|
|
3025
|
+
async function saveTiming() {
|
|
3026
|
+
const timing = {};
|
|
3027
|
+
for (const s of scenes) {
|
|
3028
|
+
timing[s.name] = s.startMs;
|
|
3029
|
+
}
|
|
3030
|
+
await fetch('/api/save-timing', {
|
|
3031
|
+
method: 'POST',
|
|
3032
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3033
|
+
body: JSON.stringify({ timing }),
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2422
3037
|
function previewEffect(sceneName, index) {
|
|
2423
3038
|
const s = scenes.find(sc => sc.name === sceneName);
|
|
2424
3039
|
if (!s?.effects?.[index]) return;
|
|
@@ -2556,7 +3171,7 @@ async function seekAbsoluteMs(absoluteMs) {
|
|
|
2556
3171
|
return;
|
|
2557
3172
|
}
|
|
2558
3173
|
|
|
2559
|
-
const totalMs =
|
|
3174
|
+
const totalMs = getPreviewDurationMs();
|
|
2560
3175
|
if (totalMs > 0) {
|
|
2561
3176
|
const pct = (targetMs / totalMs) * 100;
|
|
2562
3177
|
timelineProgress.style.width = pct + '%';
|
|
@@ -2652,8 +3267,9 @@ async function regenClip(sceneName, btn) {
|
|
|
2652
3267
|
setStatus('Regenerating TTS for ' + sceneName + '...', 'saving');
|
|
2653
3268
|
|
|
2654
3269
|
try {
|
|
2655
|
-
// Save current voiceover state first
|
|
3270
|
+
// Save current voiceover + timing state first (new scenes need timing marks)
|
|
2656
3271
|
await saveVoiceover();
|
|
3272
|
+
await saveTiming();
|
|
2657
3273
|
|
|
2658
3274
|
const resp = await fetch('/api/regen-clip', {
|
|
2659
3275
|
method: 'POST',
|
|
@@ -2688,6 +3304,23 @@ async function regenClip(sceneName, btn) {
|
|
|
2688
3304
|
}
|
|
2689
3305
|
}
|
|
2690
3306
|
|
|
3307
|
+
// Sync DOM form values back to in-memory scenes array.
|
|
3308
|
+
// Called before renderSceneList() to preserve user edits.
|
|
3309
|
+
function syncFormValuesToScenes() {
|
|
3310
|
+
for (const s of scenes) {
|
|
3311
|
+
const textEl = document.querySelector('textarea[data-scene="' + s.name + '"][data-field="text"]');
|
|
3312
|
+
if (textEl && textEl.value) {
|
|
3313
|
+
if (!s.vo) s.vo = { scene: s.name, text: '' };
|
|
3314
|
+
s.vo.text = textEl.value;
|
|
3315
|
+
}
|
|
3316
|
+
const voiceEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="voice"]');
|
|
3317
|
+
if (voiceEl?.value && s.vo) s.vo.voice = voiceEl.value;
|
|
3318
|
+
const speedEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="speed"]');
|
|
3319
|
+
if (speedEl?.value && s.vo) s.vo.speed = parseFloat(speedEl.value);
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
|
|
2691
3324
|
function collectVoiceover() {
|
|
2692
3325
|
return scenes.map(s => {
|
|
2693
3326
|
const textEl = document.querySelector('textarea[data-scene="' + s.name + '"][data-field="text"]');
|
|
@@ -2706,43 +3339,10 @@ function collectVoiceover() {
|
|
|
2706
3339
|
}
|
|
2707
3340
|
|
|
2708
3341
|
function collectOverlays() {
|
|
3342
|
+
// Serialize from s.overlay (single source of truth) — no DOM reading
|
|
2709
3343
|
return scenes
|
|
2710
|
-
.
|
|
2711
|
-
|
|
2712
|
-
const placeEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-placement"]');
|
|
2713
|
-
const motionEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-motion"]');
|
|
2714
|
-
const textEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-text"]');
|
|
2715
|
-
const bodyEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-body"]');
|
|
2716
|
-
const kickerEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-kicker"]');
|
|
2717
|
-
const srcEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-src"]');
|
|
2718
|
-
const type = typeEl?.value;
|
|
2719
|
-
if (!type) return null;
|
|
2720
|
-
const entry = {
|
|
2721
|
-
...(s.overlay ?? {}),
|
|
2722
|
-
scene: s.name,
|
|
2723
|
-
type,
|
|
2724
|
-
placement: placeEl?.value ?? 'bottom-center',
|
|
2725
|
-
};
|
|
2726
|
-
if (motionEl?.value && motionEl.value !== 'none') entry.motion = motionEl.value;
|
|
2727
|
-
else delete entry.motion;
|
|
2728
|
-
|
|
2729
|
-
delete entry.text;
|
|
2730
|
-
delete entry.title;
|
|
2731
|
-
delete entry.body;
|
|
2732
|
-
delete entry.kicker;
|
|
2733
|
-
delete entry.src;
|
|
2734
|
-
|
|
2735
|
-
if (type === 'lower-third' || type === 'callout') {
|
|
2736
|
-
entry.text = textEl?.value ?? '';
|
|
2737
|
-
} else {
|
|
2738
|
-
entry.title = textEl?.value ?? '';
|
|
2739
|
-
if (bodyEl?.value) entry.body = bodyEl.value;
|
|
2740
|
-
if (type === 'headline-card' && kickerEl?.value) entry.kicker = kickerEl.value;
|
|
2741
|
-
if (type === 'image-card' && srcEl?.value) entry.src = srcEl.value;
|
|
2742
|
-
}
|
|
2743
|
-
return entry;
|
|
2744
|
-
})
|
|
2745
|
-
.filter(Boolean);
|
|
3344
|
+
.filter(s => s.overlay?.type)
|
|
3345
|
+
.map(s => ({ ...s.overlay, scene: s.name }));
|
|
2746
3346
|
}
|
|
2747
3347
|
|
|
2748
3348
|
async function saveVoiceover() {
|
|
@@ -2754,7 +3354,7 @@ async function saveVoiceover() {
|
|
|
2754
3354
|
});
|
|
2755
3355
|
}
|
|
2756
3356
|
|
|
2757
|
-
// Render-only preview (no disk write) — called on
|
|
3357
|
+
// Render-only preview (no disk write) — called on overlay field edits
|
|
2758
3358
|
async function previewOverlays() {
|
|
2759
3359
|
const ov = collectOverlays();
|
|
2760
3360
|
const resp = await fetch('/api/render-overlays', {
|
|
@@ -2765,9 +3365,7 @@ async function previewOverlays() {
|
|
|
2765
3365
|
const result = await resp.json();
|
|
2766
3366
|
if (result.renderedOverlays) {
|
|
2767
3367
|
DATA.renderedOverlays = result.renderedOverlays;
|
|
2768
|
-
DATA.overlays = ov;
|
|
2769
3368
|
for (const s of scenes) {
|
|
2770
|
-
s.overlay = DATA.overlays.find(o => o.scene === s.name);
|
|
2771
3369
|
s.rendered = DATA.renderedOverlays[s.name];
|
|
2772
3370
|
}
|
|
2773
3371
|
renderOverlayElements();
|
|
@@ -2786,9 +3384,7 @@ async function saveOverlays() {
|
|
|
2786
3384
|
const result = await resp.json();
|
|
2787
3385
|
if (result.renderedOverlays) {
|
|
2788
3386
|
DATA.renderedOverlays = result.renderedOverlays;
|
|
2789
|
-
DATA.overlays = ov;
|
|
2790
3387
|
for (const s of scenes) {
|
|
2791
|
-
s.overlay = DATA.overlays.find(o => o.scene === s.name);
|
|
2792
3388
|
s.rendered = DATA.renderedOverlays[s.name];
|
|
2793
3389
|
}
|
|
2794
3390
|
renderOverlayElements();
|
|
@@ -2824,28 +3420,12 @@ function isSceneModified(sceneName) {
|
|
|
2824
3420
|
const voice = card.querySelector('[data-field="voice"]')?.value ?? '';
|
|
2825
3421
|
const speed = card.querySelector('[data-field="speed"]')?.value ?? '';
|
|
2826
3422
|
if (text !== snap.text || voice !== snap.voice || String(speed) !== String(snap.speed)) return true;
|
|
2827
|
-
// Check overlay
|
|
2828
|
-
const type = card.querySelector('[data-field="overlay-type"]')?.value ?? '';
|
|
2829
|
-
const snapType = snap.overlay?.type ?? '';
|
|
2830
|
-
if (type !== snapType) return true;
|
|
2831
|
-
if (type) {
|
|
2832
|
-
const placement = card.querySelector('[data-field="overlay-placement"]')?.value ?? '';
|
|
2833
|
-
const motion = card.querySelector('[data-field="overlay-motion"]')?.value ?? '';
|
|
2834
|
-
const overlayText = card.querySelector('[data-field="overlay-text"]')?.value ?? '';
|
|
2835
|
-
const body = card.querySelector('[data-field="overlay-body"]')?.value ?? '';
|
|
2836
|
-
const kicker = card.querySelector('[data-field="overlay-kicker"]')?.value ?? '';
|
|
2837
|
-
const src = card.querySelector('[data-field="overlay-src"]')?.value ?? '';
|
|
2838
|
-
const so = snap.overlay || {};
|
|
2839
|
-
if (placement !== (so.placement ?? 'bottom-center')) return true;
|
|
2840
|
-
if (motion !== (so.motion ?? 'none')) return true;
|
|
2841
|
-
const snapText = so.type === 'lower-third' || so.type === 'callout' ? (so.text ?? '') : (so.title ?? '');
|
|
2842
|
-
if (overlayText !== snapText) return true;
|
|
2843
|
-
if (body !== (so.body ?? '')) return true;
|
|
2844
|
-
if (kicker !== (so.kicker ?? '')) return true;
|
|
2845
|
-
if (src !== (so.src ?? '')) return true;
|
|
2846
|
-
}
|
|
2847
|
-
// Check effects
|
|
3423
|
+
// Check overlay and effects from s.overlay / s.effects (single source of truth)
|
|
2848
3424
|
const s = scenes.find(sc => sc.name === sceneName);
|
|
3425
|
+
const currentOverlay = s?.overlay;
|
|
3426
|
+
const snapOverlay = snap.overlay;
|
|
3427
|
+
if (JSON.stringify(currentOverlay ?? null) !== JSON.stringify(snapOverlay ?? null)) return true;
|
|
3428
|
+
// Check effects
|
|
2849
3429
|
const currentEffects = JSON.stringify(s?.effects ?? []);
|
|
2850
3430
|
const snapEffects = JSON.stringify(snap.effects ?? []);
|
|
2851
3431
|
if (currentEffects !== snapEffects) return true;
|
|
@@ -2882,13 +3462,17 @@ function undoScene(sceneName) {
|
|
|
2882
3462
|
s.effects = snap.effects?.length ? JSON.parse(JSON.stringify(snap.effects)) : [];
|
|
2883
3463
|
refreshEffectsUI(sceneName);
|
|
2884
3464
|
}
|
|
2885
|
-
// Restore overlay
|
|
3465
|
+
// Restore overlay from snapshot into s.overlay (single source of truth)
|
|
3466
|
+
if (s) {
|
|
3467
|
+
s.overlay = snap.overlay ? JSON.parse(JSON.stringify(snap.overlay)) : undefined;
|
|
3468
|
+
}
|
|
3469
|
+
// Restore overlay type (triggers field re-render via updateOverlayFieldsForScene)
|
|
2886
3470
|
const typeEl = card.querySelector('[data-field="overlay-type"]');
|
|
2887
3471
|
if (typeEl) {
|
|
2888
3472
|
typeEl.value = snap.overlay?.type ?? '';
|
|
2889
3473
|
updateOverlayFieldsForScene(sceneName);
|
|
2890
3474
|
}
|
|
2891
|
-
// Restore overlay field values after re-render
|
|
3475
|
+
// Restore overlay field values in DOM after re-render
|
|
2892
3476
|
setTimeout(() => {
|
|
2893
3477
|
const so = snap.overlay || {};
|
|
2894
3478
|
const textField = card.querySelector('[data-field="overlay-text"]');
|
|
@@ -2905,8 +3489,8 @@ function undoScene(sceneName) {
|
|
|
2905
3489
|
if (placementField) placementField.value = so.placement ?? 'bottom-center';
|
|
2906
3490
|
const motionField = card.querySelector('[data-field="overlay-motion"]');
|
|
2907
3491
|
if (motionField) motionField.value = so.motion ?? 'none';
|
|
2908
|
-
// Re-render overlay preview
|
|
2909
|
-
|
|
3492
|
+
// Re-render overlay preview for this scene only
|
|
3493
|
+
renderSingleSceneOverlay(sceneName);
|
|
2910
3494
|
updateUndoButton(sceneName);
|
|
2911
3495
|
// Check if all scenes are back to saved state
|
|
2912
3496
|
const anyModified = scenes.some(s => isSceneModified(s.name));
|
|
@@ -2942,6 +3526,7 @@ document.getElementById('btn-save').addEventListener('click', async () => {
|
|
|
2942
3526
|
await saveVoiceover();
|
|
2943
3527
|
await saveOverlays();
|
|
2944
3528
|
await saveEffects();
|
|
3529
|
+
await saveTiming();
|
|
2945
3530
|
clearDirty();
|
|
2946
3531
|
setStatus('All changes saved', 'saved');
|
|
2947
3532
|
saveBtn.textContent = '\\u2713 Saved';
|
|
@@ -2959,13 +3544,12 @@ document.getElementById('btn-save').addEventListener('click', async () => {
|
|
|
2959
3544
|
|
|
2960
3545
|
// Export button (re-align audio + export MP4, no re-recording)
|
|
2961
3546
|
document.getElementById('btn-export').addEventListener('click', async () => {
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
}
|
|
3547
|
+
// Always save before export to ensure timing marks + voiceover are persisted
|
|
3548
|
+
await saveVoiceover();
|
|
3549
|
+
await saveOverlays();
|
|
3550
|
+
await saveEffects();
|
|
3551
|
+
await saveTiming();
|
|
3552
|
+
clearDirty();
|
|
2969
3553
|
const overlay = document.getElementById('recording-overlay');
|
|
2970
3554
|
const title = document.getElementById('recording-title');
|
|
2971
3555
|
const subtitle = document.getElementById('recording-subtitle');
|
|
@@ -3008,6 +3592,7 @@ document.getElementById('btn-rerecord').addEventListener('click', async () => {
|
|
|
3008
3592
|
await saveVoiceover();
|
|
3009
3593
|
await saveOverlays();
|
|
3010
3594
|
await saveEffects();
|
|
3595
|
+
await saveTiming();
|
|
3011
3596
|
clearDirty();
|
|
3012
3597
|
}
|
|
3013
3598
|
const overlay = document.getElementById('recording-overlay');
|