@argo-video/cli 0.22.0 → 0.23.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 +31 -6
- 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 +699 -79
- 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;
|
|
427
|
+
}
|
|
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
|
+
}
|
|
328
440
|
}
|
|
329
|
-
|
|
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,47 @@ 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: auto;
|
|
1294
|
+
}
|
|
1295
|
+
.overlay-cue.overlay-draggable:active {
|
|
1296
|
+
cursor: grabbing;
|
|
1297
|
+
}
|
|
1298
|
+
.overlay-cue.overlay-dragging {
|
|
1299
|
+
cursor: grabbing;
|
|
1300
|
+
opacity: 0.85;
|
|
1301
|
+
z-index: 20;
|
|
1302
|
+
}
|
|
1303
|
+
.snap-zone {
|
|
1304
|
+
position: absolute;
|
|
1305
|
+
border: 2px dashed rgba(99, 102, 241, 0.4);
|
|
1306
|
+
border-radius: 8px;
|
|
1307
|
+
pointer-events: none;
|
|
1308
|
+
opacity: 0;
|
|
1309
|
+
transition: opacity 0.2s;
|
|
1310
|
+
z-index: 15;
|
|
1311
|
+
}
|
|
1312
|
+
.snap-zone.visible {
|
|
1313
|
+
opacity: 1;
|
|
1314
|
+
}
|
|
1315
|
+
.snap-zone.highlight {
|
|
1316
|
+
background: rgba(99, 102, 241, 0.15);
|
|
1317
|
+
border-color: rgba(99, 102, 241, 0.8);
|
|
1318
|
+
}
|
|
1319
|
+
.snap-zone-label {
|
|
1320
|
+
position: absolute;
|
|
1321
|
+
bottom: 4px;
|
|
1322
|
+
left: 50%;
|
|
1323
|
+
transform: translateX(-50%);
|
|
1324
|
+
font-size: 11px;
|
|
1325
|
+
color: rgba(99, 102, 241, 0.8);
|
|
1326
|
+
font-family: var(--mono);
|
|
1327
|
+
white-space: nowrap;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1096
1330
|
/* Timeline bar */
|
|
1097
1331
|
.timeline {
|
|
1098
1332
|
background: var(--surface);
|
|
@@ -1433,6 +1667,24 @@ const PREVIEW_HTML = `<!DOCTYPE html>
|
|
|
1433
1667
|
border-radius: 4px;
|
|
1434
1668
|
}
|
|
1435
1669
|
|
|
1670
|
+
.add-scene-btn {
|
|
1671
|
+
width: 100%;
|
|
1672
|
+
padding: 10px;
|
|
1673
|
+
margin-bottom: 12px;
|
|
1674
|
+
background: var(--bg-card, var(--surface));
|
|
1675
|
+
border: 2px dashed var(--border);
|
|
1676
|
+
border-radius: 10px;
|
|
1677
|
+
color: var(--text-muted);
|
|
1678
|
+
cursor: pointer;
|
|
1679
|
+
font-family: var(--mono);
|
|
1680
|
+
font-size: 0.85rem;
|
|
1681
|
+
transition: all 0.2s;
|
|
1682
|
+
}
|
|
1683
|
+
.add-scene-btn:hover {
|
|
1684
|
+
border-color: var(--accent);
|
|
1685
|
+
color: var(--accent);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1436
1688
|
/* Editable fields */
|
|
1437
1689
|
.field-group { margin-top: 8px; }
|
|
1438
1690
|
.field-group label {
|
|
@@ -1697,6 +1949,12 @@ const PREVIEW_HTML = `<!DOCTYPE html>
|
|
|
1697
1949
|
<div class="video-container">
|
|
1698
1950
|
<video id="video" src="/video" preload="auto" muted playsinline></video>
|
|
1699
1951
|
<div class="overlay-layer" id="overlay-layer"></div>
|
|
1952
|
+
<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>
|
|
1953
|
+
<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>
|
|
1954
|
+
<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>
|
|
1955
|
+
<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>
|
|
1956
|
+
<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>
|
|
1957
|
+
<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
1958
|
</div>
|
|
1701
1959
|
|
|
1702
1960
|
<div class="timeline">
|
|
@@ -1737,6 +1995,7 @@ const PREVIEW_HTML = `<!DOCTYPE html>
|
|
|
1737
1995
|
<button class="sidebar-tab" data-tab="metadata">Metadata</button>
|
|
1738
1996
|
</div>
|
|
1739
1997
|
<div class="sidebar-panel" id="panel-scenes">
|
|
1998
|
+
<button id="add-scene-btn" class="add-scene-btn">+ Add scene at current time</button>
|
|
1740
1999
|
<div id="scene-list"></div>
|
|
1741
2000
|
<div class="music-panel" id="music-panel">
|
|
1742
2001
|
<div class="music-panel-header" id="music-panel-header">
|
|
@@ -1839,12 +2098,19 @@ const scenes = Object.entries(DATA.timing)
|
|
|
1839
2098
|
|
|
1840
2099
|
let activeScene = null;
|
|
1841
2100
|
|
|
1842
|
-
|
|
1843
|
-
video.
|
|
1844
|
-
|
|
1845
|
-
|
|
2101
|
+
function getPreviewDurationMs() {
|
|
2102
|
+
const mediaDurationMs = Number.isFinite(video.duration) && video.duration > 0
|
|
2103
|
+
? video.duration * 1000
|
|
2104
|
+
: 0;
|
|
2105
|
+
return mediaDurationMs || DATA.videoDurationMs || DATA.sceneReport?.totalDurationMs || 0;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
function renderTimelineMarkers() {
|
|
2109
|
+
const totalMs = getPreviewDurationMs();
|
|
2110
|
+
if (!totalMs) return;
|
|
2111
|
+
|
|
2112
|
+
timelineBar.querySelectorAll('.timeline-scene').forEach(node => node.remove());
|
|
1846
2113
|
|
|
1847
|
-
// Render scene markers on timeline
|
|
1848
2114
|
scenes.forEach((s, i) => {
|
|
1849
2115
|
const pct = (s.startMs / totalMs) * 100;
|
|
1850
2116
|
const nextStart = i + 1 < scenes.length ? scenes[i + 1].startMs : totalMs;
|
|
@@ -1855,22 +2121,37 @@ video.addEventListener('loadedmetadata', () => {
|
|
|
1855
2121
|
marker.style.left = pct + '%';
|
|
1856
2122
|
marker.style.width = Math.max(widthPct, 2) + '%';
|
|
1857
2123
|
const hasOverlay = DATA.overlays.find(o => o.scene === s.name);
|
|
1858
|
-
// s.name is already escaped via esc() — safe for innerHTML
|
|
1859
2124
|
marker.innerHTML = esc(s.name) + (hasOverlay ? '<span class="has-overlay"></span>' : '');
|
|
1860
2125
|
marker.dataset.scene = s.name;
|
|
1861
2126
|
marker.addEventListener('click', (e) => {
|
|
1862
2127
|
e.stopPropagation();
|
|
2128
|
+
// Don't seek to scene start if user just finished scrubbing
|
|
2129
|
+
if (justScrubbed) return;
|
|
1863
2130
|
seekToScene(s);
|
|
1864
2131
|
});
|
|
1865
2132
|
timelineBar.appendChild(marker);
|
|
1866
2133
|
});
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
// ─── Timeline ──────────────────────────────────────────────────────────────
|
|
2137
|
+
video.addEventListener('loadedmetadata', () => {
|
|
2138
|
+
const totalMs = getPreviewDurationMs();
|
|
2139
|
+
document.getElementById('time-total').textContent = formatTime(totalMs);
|
|
2140
|
+
renderTimelineMarkers();
|
|
1867
2141
|
|
|
1868
2142
|
// Create overlay DOM elements
|
|
1869
2143
|
renderOverlayElements();
|
|
1870
2144
|
});
|
|
1871
2145
|
|
|
2146
|
+
if (getPreviewDurationMs() > 0) {
|
|
2147
|
+
document.getElementById('time-total').textContent = formatTime(getPreviewDurationMs());
|
|
2148
|
+
renderTimelineMarkers();
|
|
2149
|
+
}
|
|
2150
|
+
|
|
1872
2151
|
video.addEventListener('timeupdate', () => {
|
|
1873
|
-
|
|
2152
|
+
// Don't update UI during scrubbing — scrubToX handles it directly
|
|
2153
|
+
if (isScrubbing) return;
|
|
2154
|
+
const totalMs = getPreviewDurationMs();
|
|
1874
2155
|
const currentMs = video.currentTime * 1000;
|
|
1875
2156
|
if (scenePlaybackEndMs !== null && currentMs >= scenePlaybackEndMs) {
|
|
1876
2157
|
const stopAt = scenePlaybackEndMs;
|
|
@@ -1899,13 +2180,47 @@ video.addEventListener('timeupdate', () => {
|
|
|
1899
2180
|
}
|
|
1900
2181
|
});
|
|
1901
2182
|
|
|
1902
|
-
// Click on timeline bar to
|
|
1903
|
-
|
|
2183
|
+
// Click and drag on timeline bar to scrub
|
|
2184
|
+
let isScrubbing = false;
|
|
2185
|
+
let justScrubbed = false;
|
|
2186
|
+
|
|
2187
|
+
function scrubToX(clientX) {
|
|
1904
2188
|
const rect = timelineBar.getBoundingClientRect();
|
|
1905
|
-
const pct = (
|
|
1906
|
-
const
|
|
2189
|
+
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
2190
|
+
const dur = video.duration;
|
|
2191
|
+
if (!dur || !Number.isFinite(dur)) return;
|
|
2192
|
+
const targetSec = pct * dur;
|
|
1907
2193
|
scenePlaybackEndMs = null;
|
|
1908
|
-
|
|
2194
|
+
// Direct assignment — no async seek during scrubbing
|
|
2195
|
+
video.currentTime = targetSec;
|
|
2196
|
+
// Update UI immediately
|
|
2197
|
+
const ms = targetSec * 1000;
|
|
2198
|
+
document.getElementById('time-current').textContent = formatTime(ms);
|
|
2199
|
+
timelineProgress.style.width = (pct * 100) + '%';
|
|
2200
|
+
document.getElementById('timeline-playhead').style.left = (pct * 100) + '%';
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
timelineBar.addEventListener('mousedown', (e) => {
|
|
2204
|
+
isScrubbing = true;
|
|
2205
|
+
video.pause();
|
|
2206
|
+
showPlayIcon();
|
|
2207
|
+
scrubToX(e.clientX);
|
|
2208
|
+
e.preventDefault();
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
document.addEventListener('mousemove', (e) => {
|
|
2212
|
+
if (!isScrubbing) return;
|
|
2213
|
+
scrubToX(e.clientX);
|
|
2214
|
+
e.preventDefault();
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
document.addEventListener('mouseup', () => {
|
|
2218
|
+
if (isScrubbing) {
|
|
2219
|
+
isScrubbing = false;
|
|
2220
|
+
// Prevent the subsequent click event on scene markers from seeking to scene start
|
|
2221
|
+
justScrubbed = true;
|
|
2222
|
+
setTimeout(() => { justScrubbed = false; }, 50);
|
|
2223
|
+
}
|
|
1909
2224
|
});
|
|
1910
2225
|
|
|
1911
2226
|
// Play/pause icon toggling
|
|
@@ -2005,6 +2320,7 @@ function renderOverlayElements() {
|
|
|
2005
2320
|
el.innerHTML = '<span class="preview-badge">PREVIEW</span>' + s.rendered.html;
|
|
2006
2321
|
Object.assign(el.style, s.rendered.styles);
|
|
2007
2322
|
overlayLayer.appendChild(el);
|
|
2323
|
+
makeOverlayDraggable(el);
|
|
2008
2324
|
}
|
|
2009
2325
|
}
|
|
2010
2326
|
|
|
@@ -2018,9 +2334,13 @@ function updateOverlayVisibility(currentMs) {
|
|
|
2018
2334
|
const el = overlayLayer.querySelector('[data-scene="' + s.name + '"]');
|
|
2019
2335
|
if (!el) continue;
|
|
2020
2336
|
|
|
2021
|
-
// Show overlay
|
|
2337
|
+
// Show overlay during this scene's time range.
|
|
2338
|
+
// For scenes without TTS (endMs === startMs), extend to the next scene's start or video end.
|
|
2022
2339
|
const { startMs, endMs } = getSceneBounds(s);
|
|
2023
|
-
const
|
|
2340
|
+
const sceneIdx = scenes.indexOf(s);
|
|
2341
|
+
const nextStart = sceneIdx + 1 < scenes.length ? scenes[sceneIdx + 1].startMs : getPreviewDurationMs();
|
|
2342
|
+
const effectiveEnd = endMs > startMs ? endMs : nextStart;
|
|
2343
|
+
const isActive = currentMs >= startMs && currentMs < effectiveEnd;
|
|
2024
2344
|
el.classList.toggle('visible', isActive);
|
|
2025
2345
|
}
|
|
2026
2346
|
}
|
|
@@ -2029,6 +2349,165 @@ document.getElementById('cb-overlays').addEventListener('change', () => {
|
|
|
2029
2349
|
updateOverlayVisibility(video.currentTime * 1000);
|
|
2030
2350
|
});
|
|
2031
2351
|
|
|
2352
|
+
// ─── Drag-to-snap overlay positioning ──────────────────────────────────────
|
|
2353
|
+
const SNAP_ZONES = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'bottom-center', 'center'];
|
|
2354
|
+
const snapZoneEls = document.querySelectorAll('.snap-zone');
|
|
2355
|
+
|
|
2356
|
+
// Zone center positions as fractions of the container
|
|
2357
|
+
const ZONE_CENTERS = {
|
|
2358
|
+
'top-left': { x: 0.05 + 0.35 / 2, y: 0.10 + 0.35 / 2 },
|
|
2359
|
+
'top-right': { x: 1 - 0.05 - 0.35 / 2, y: 0.10 + 0.35 / 2 },
|
|
2360
|
+
'bottom-left': { x: 0.05 + 0.35 / 2, y: 1 - 0.10 - 0.35 / 2 },
|
|
2361
|
+
'bottom-right': { x: 1 - 0.05 - 0.35 / 2, y: 1 - 0.10 - 0.35 / 2 },
|
|
2362
|
+
'bottom-center': { x: 0.25 + 0.50 / 2, y: 1 - 0.05 - 0.20 / 2 },
|
|
2363
|
+
'center': { x: 0.25 + 0.50 / 2, y: 0.30 + 0.40 / 2 },
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
let dragState = null;
|
|
2367
|
+
let isOverlayDragging = false;
|
|
2368
|
+
|
|
2369
|
+
function showSnapZones() {
|
|
2370
|
+
snapZoneEls.forEach(el => el.classList.add('visible'));
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
function hideSnapZones() {
|
|
2374
|
+
snapZoneEls.forEach(el => {
|
|
2375
|
+
el.classList.remove('visible', 'highlight');
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
function highlightNearestZone(fracX, fracY) {
|
|
2380
|
+
let nearest = null;
|
|
2381
|
+
let minDist = Infinity;
|
|
2382
|
+
for (const zone of SNAP_ZONES) {
|
|
2383
|
+
const c = ZONE_CENTERS[zone];
|
|
2384
|
+
const d = Math.hypot(fracX - c.x, fracY - c.y);
|
|
2385
|
+
if (d < minDist) {
|
|
2386
|
+
minDist = d;
|
|
2387
|
+
nearest = zone;
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
snapZoneEls.forEach(el => {
|
|
2391
|
+
el.classList.toggle('highlight', el.dataset.zone === nearest);
|
|
2392
|
+
});
|
|
2393
|
+
return nearest;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
function makeOverlayDraggable(el) {
|
|
2397
|
+
el.classList.add('overlay-draggable');
|
|
2398
|
+
|
|
2399
|
+
el.addEventListener('mousedown', (e) => {
|
|
2400
|
+
// Only primary button
|
|
2401
|
+
if (e.button !== 0) return;
|
|
2402
|
+
e.preventDefault();
|
|
2403
|
+
e.stopPropagation();
|
|
2404
|
+
|
|
2405
|
+
const container = el.closest('.video-container');
|
|
2406
|
+
if (!container) return;
|
|
2407
|
+
const containerRect = container.getBoundingClientRect();
|
|
2408
|
+
const elRect = el.getBoundingClientRect();
|
|
2409
|
+
|
|
2410
|
+
// Store original zone positioning styles so we can restore if needed
|
|
2411
|
+
const sceneName = el.dataset.scene;
|
|
2412
|
+
|
|
2413
|
+
dragState = {
|
|
2414
|
+
el,
|
|
2415
|
+
sceneName,
|
|
2416
|
+
container,
|
|
2417
|
+
containerRect,
|
|
2418
|
+
// Offset from mouse to element top-left
|
|
2419
|
+
offsetX: e.clientX - elRect.left,
|
|
2420
|
+
offsetY: e.clientY - elRect.top,
|
|
2421
|
+
nearestZone: null,
|
|
2422
|
+
};
|
|
2423
|
+
|
|
2424
|
+
// Switch to fixed positioning for free drag
|
|
2425
|
+
isOverlayDragging = true;
|
|
2426
|
+
el.classList.add('overlay-dragging');
|
|
2427
|
+
el.style.position = 'absolute';
|
|
2428
|
+
el.style.left = (elRect.left - containerRect.left) + 'px';
|
|
2429
|
+
el.style.top = (elRect.top - containerRect.top) + 'px';
|
|
2430
|
+
el.style.right = 'auto';
|
|
2431
|
+
el.style.bottom = 'auto';
|
|
2432
|
+
el.style.transform = 'none';
|
|
2433
|
+
|
|
2434
|
+
showSnapZones();
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
document.addEventListener('mousemove', (e) => {
|
|
2439
|
+
if (!dragState) return;
|
|
2440
|
+
e.preventDefault();
|
|
2441
|
+
|
|
2442
|
+
const { el, container, containerRect, offsetX, offsetY } = dragState;
|
|
2443
|
+
const rect = containerRect;
|
|
2444
|
+
|
|
2445
|
+
const newLeft = e.clientX - rect.left - offsetX;
|
|
2446
|
+
const newTop = e.clientY - rect.top - offsetY;
|
|
2447
|
+
|
|
2448
|
+
el.style.left = newLeft + 'px';
|
|
2449
|
+
el.style.top = newTop + 'px';
|
|
2450
|
+
|
|
2451
|
+
// Calculate overlay center as fraction of container
|
|
2452
|
+
const elRect = el.getBoundingClientRect();
|
|
2453
|
+
const centerX = (elRect.left + elRect.width / 2 - rect.left) / rect.width;
|
|
2454
|
+
const centerY = (elRect.top + elRect.height / 2 - rect.top) / rect.height;
|
|
2455
|
+
|
|
2456
|
+
dragState.nearestZone = highlightNearestZone(centerX, centerY);
|
|
2457
|
+
});
|
|
2458
|
+
|
|
2459
|
+
document.addEventListener('mouseup', (e) => {
|
|
2460
|
+
if (!dragState) return;
|
|
2461
|
+
|
|
2462
|
+
const { el, sceneName, nearestZone } = dragState;
|
|
2463
|
+
const zone = nearestZone || 'bottom-center';
|
|
2464
|
+
|
|
2465
|
+
// Remove drag styles — let CSS zone positioning take over
|
|
2466
|
+
el.classList.remove('overlay-dragging');
|
|
2467
|
+
el.style.position = '';
|
|
2468
|
+
el.style.left = '';
|
|
2469
|
+
el.style.top = '';
|
|
2470
|
+
el.style.right = '';
|
|
2471
|
+
el.style.bottom = '';
|
|
2472
|
+
el.style.transform = '';
|
|
2473
|
+
|
|
2474
|
+
// Update the data-zone attribute so CSS positioning applies
|
|
2475
|
+
el.dataset.zone = zone;
|
|
2476
|
+
|
|
2477
|
+
// Update the placement dropdown in the scene card (no change event — avoids re-rendering all overlays)
|
|
2478
|
+
const placeEl = document.querySelector('select[data-scene="' + sceneName + '"][data-field="overlay-placement"]');
|
|
2479
|
+
if (placeEl) {
|
|
2480
|
+
placeEl.value = zone;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
// Update the scene's overlay data directly
|
|
2484
|
+
const s = scenes.find(sc => sc.name === sceneName);
|
|
2485
|
+
if (s && s.overlay) {
|
|
2486
|
+
s.overlay.placement = zone;
|
|
2487
|
+
}
|
|
2488
|
+
const dataOverlay = DATA.overlays.find(o => o.scene === sceneName);
|
|
2489
|
+
if (dataOverlay) {
|
|
2490
|
+
dataOverlay.placement = zone;
|
|
2491
|
+
}
|
|
2492
|
+
if (DATA.renderedOverlays[sceneName]) {
|
|
2493
|
+
DATA.renderedOverlays[sceneName].zone = zone;
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
hideSnapZones();
|
|
2497
|
+
markDirty();
|
|
2498
|
+
// Keep isOverlayDragging true until after the wireOverlayListeners debounce (300ms)
|
|
2499
|
+
// would have fired, to prevent any overlay field handler from triggering a re-render
|
|
2500
|
+
setTimeout(() => { isOverlayDragging = false; }, 500);
|
|
2501
|
+
|
|
2502
|
+
// Placement-only drag should not trigger a full overlay preview refresh:
|
|
2503
|
+
// previewOverlays() re-scrapes every scene card from the DOM, which can
|
|
2504
|
+
// pull stale values from other scenes while the user is dragging. The
|
|
2505
|
+
// dragged element already has the new zone applied locally.
|
|
2506
|
+
updateOverlayVisibility(video.currentTime * 1000);
|
|
2507
|
+
|
|
2508
|
+
dragState = null;
|
|
2509
|
+
});
|
|
2510
|
+
|
|
2032
2511
|
// ─── Scene list (sidebar) ──────────────────────────────────────────────────
|
|
2033
2512
|
function renderSceneList() {
|
|
2034
2513
|
sceneList.innerHTML = '';
|
|
@@ -2122,8 +2601,66 @@ function renderSceneList() {
|
|
|
2122
2601
|
wireOverlayListeners(s.name);
|
|
2123
2602
|
wireEffectListeners(s.name);
|
|
2124
2603
|
}
|
|
2604
|
+
|
|
2605
|
+
// Trigger overlay preview after rendering so existing overlays appear on the video
|
|
2606
|
+
previewOverlays();
|
|
2125
2607
|
}
|
|
2126
2608
|
|
|
2609
|
+
// ─── Add Scene button ────────────────────────────────────────────────────
|
|
2610
|
+
document.getElementById('add-scene-btn').addEventListener('click', () => {
|
|
2611
|
+
// Generate a unique scene name
|
|
2612
|
+
let idx = scenes.length + 1;
|
|
2613
|
+
let name = 'scene-' + idx;
|
|
2614
|
+
const existingNames = new Set(scenes.map(s => s.name));
|
|
2615
|
+
while (existingNames.has(name)) {
|
|
2616
|
+
idx++;
|
|
2617
|
+
name = 'scene-' + idx;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// Timestamp from current video position
|
|
2621
|
+
const startMs = Math.round(video.currentTime * 1000);
|
|
2622
|
+
|
|
2623
|
+
// Add to timing data
|
|
2624
|
+
DATA.timing[name] = startMs;
|
|
2625
|
+
DATA.sceneDurations[name] = 0;
|
|
2626
|
+
DATA.voiceover.push({ scene: name, text: '' });
|
|
2627
|
+
|
|
2628
|
+
// Insert scene into the sorted array at the right position
|
|
2629
|
+
const newScene = {
|
|
2630
|
+
name,
|
|
2631
|
+
startMs,
|
|
2632
|
+
vo: { scene: name, text: '' },
|
|
2633
|
+
overlay: undefined,
|
|
2634
|
+
effects: [],
|
|
2635
|
+
rendered: undefined,
|
|
2636
|
+
report: undefined,
|
|
2637
|
+
};
|
|
2638
|
+
// Find insertion index to keep sorted by startMs
|
|
2639
|
+
let insertIdx = scenes.length;
|
|
2640
|
+
for (let i = 0; i < scenes.length; i++) {
|
|
2641
|
+
if (scenes[i].startMs > startMs) {
|
|
2642
|
+
insertIdx = i;
|
|
2643
|
+
break;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
scenes.splice(insertIdx, 0, newScene);
|
|
2647
|
+
|
|
2648
|
+
// Capture current form values before re-render wipes the DOM
|
|
2649
|
+
syncFormValuesToScenes();
|
|
2650
|
+
|
|
2651
|
+
// Re-render scene list
|
|
2652
|
+
renderSceneList();
|
|
2653
|
+
snapshotAllScenes();
|
|
2654
|
+
markDirty();
|
|
2655
|
+
|
|
2656
|
+
// Auto-scroll to the new card and expand it
|
|
2657
|
+
const newCard = document.querySelector('.scene-card[data-scene="' + name + '"]');
|
|
2658
|
+
if (newCard) {
|
|
2659
|
+
newCard.classList.add('expanded');
|
|
2660
|
+
newCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
2661
|
+
}
|
|
2662
|
+
});
|
|
2663
|
+
|
|
2127
2664
|
function renderDynamicOverlayFields(sceneName, type, ov) {
|
|
2128
2665
|
if (!type) return '';
|
|
2129
2666
|
let fields = '';
|
|
@@ -2192,6 +2729,13 @@ function renderOverlayFields(s) {
|
|
|
2192
2729
|
<option value="bottom-right" \${ov?.placement === 'bottom-right' ? 'selected' : ''}>bottom-right</option>
|
|
2193
2730
|
<option value="center" \${ov?.placement === 'center' ? 'selected' : ''}>center</option>
|
|
2194
2731
|
</select>
|
|
2732
|
+
</div>
|
|
2733
|
+
<div style="flex:0 0 auto; display:flex; align-items:flex-end; padding-bottom:2px;">
|
|
2734
|
+
<span class="overlay-theme-badge" data-scene="\${esc(s.name)}" title="Auto-detected overlay theme"
|
|
2735
|
+
style="font-size:11px; padding:2px 6px; border-radius:3px;
|
|
2736
|
+
background:\${(DATA.overlayThemes[s.name] ?? 'dark') === 'light' ? '#fff' : '#333'};
|
|
2737
|
+
color:\${(DATA.overlayThemes[s.name] ?? 'dark') === 'light' ? '#333' : '#ccc'};
|
|
2738
|
+
border:1px solid #555;">\${DATA.overlayThemes[s.name] ?? 'dark'}</span>
|
|
2195
2739
|
</div>\` : ''}
|
|
2196
2740
|
</div>
|
|
2197
2741
|
\${type ? \`<div class="field-group">
|
|
@@ -2213,6 +2757,10 @@ function updateOverlayFieldsForScene(sceneName) {
|
|
|
2213
2757
|
const typeEl = document.querySelector('select[data-scene="' + sceneName + '"][data-field="overlay-type"]');
|
|
2214
2758
|
const type = typeEl?.value ?? '';
|
|
2215
2759
|
const s = scenes.find(sc => sc.name === sceneName);
|
|
2760
|
+
if (s) {
|
|
2761
|
+
if (!type) s.overlay = undefined;
|
|
2762
|
+
else s.overlay = { ...(s.overlay ?? {}), type };
|
|
2763
|
+
}
|
|
2216
2764
|
const ov = s?.overlay;
|
|
2217
2765
|
const container = document.querySelector('.overlay-fields-dynamic[data-scene="' + sceneName + '"]');
|
|
2218
2766
|
if (!container) return;
|
|
@@ -2227,6 +2775,8 @@ function updateOverlayFieldsForScene(sceneName) {
|
|
|
2227
2775
|
section.outerHTML = renderOverlayFields(fakeScene);
|
|
2228
2776
|
// Re-wire event listeners for the new overlay fields
|
|
2229
2777
|
wireOverlayListeners(sceneName);
|
|
2778
|
+
// Trigger preview to show the overlay immediately after type change
|
|
2779
|
+
previewOverlays();
|
|
2230
2780
|
}
|
|
2231
2781
|
}
|
|
2232
2782
|
|
|
@@ -2237,8 +2787,30 @@ function wireOverlayListeners(sceneName) {
|
|
|
2237
2787
|
card.querySelectorAll('[data-field^="overlay"]').forEach(input => {
|
|
2238
2788
|
const handler = () => {
|
|
2239
2789
|
markDirty();
|
|
2790
|
+
// Skip full re-render during overlay drag — drag only changes placement
|
|
2791
|
+
if (isOverlayDragging) return;
|
|
2792
|
+
// Sync only THIS scene's overlay from DOM on field change
|
|
2793
|
+
syncOverlayFormValuesForScene(sceneName);
|
|
2240
2794
|
clearTimeout(debounceTimer);
|
|
2241
|
-
debounceTimer = setTimeout(() =>
|
|
2795
|
+
debounceTimer = setTimeout(() => {
|
|
2796
|
+
// Only preview this scene's overlay, not all overlays
|
|
2797
|
+
const singleOv = scenes.find(sc => sc.name === sceneName);
|
|
2798
|
+
if (singleOv?.overlay) {
|
|
2799
|
+
const ov = [{ ...singleOv.overlay, scene: sceneName }];
|
|
2800
|
+
fetch('/api/render-overlays', {
|
|
2801
|
+
method: 'POST',
|
|
2802
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2803
|
+
body: JSON.stringify(ov),
|
|
2804
|
+
}).then(r => r.json()).then(result => {
|
|
2805
|
+
if (result.renderedOverlays?.[sceneName]) {
|
|
2806
|
+
DATA.renderedOverlays[sceneName] = result.renderedOverlays[sceneName];
|
|
2807
|
+
singleOv.rendered = result.renderedOverlays[sceneName];
|
|
2808
|
+
renderOverlayElements();
|
|
2809
|
+
updateOverlayVisibility(video.currentTime * 1000);
|
|
2810
|
+
}
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
}, 300);
|
|
2242
2814
|
};
|
|
2243
2815
|
input.addEventListener('input', handler);
|
|
2244
2816
|
input.addEventListener('change', handler);
|
|
@@ -2419,6 +2991,18 @@ async function saveEffects() {
|
|
|
2419
2991
|
});
|
|
2420
2992
|
}
|
|
2421
2993
|
|
|
2994
|
+
async function saveTiming() {
|
|
2995
|
+
const timing = {};
|
|
2996
|
+
for (const s of scenes) {
|
|
2997
|
+
timing[s.name] = s.startMs;
|
|
2998
|
+
}
|
|
2999
|
+
await fetch('/api/save-timing', {
|
|
3000
|
+
method: 'POST',
|
|
3001
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3002
|
+
body: JSON.stringify({ timing }),
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
|
|
2422
3006
|
function previewEffect(sceneName, index) {
|
|
2423
3007
|
const s = scenes.find(sc => sc.name === sceneName);
|
|
2424
3008
|
if (!s?.effects?.[index]) return;
|
|
@@ -2556,7 +3140,7 @@ async function seekAbsoluteMs(absoluteMs) {
|
|
|
2556
3140
|
return;
|
|
2557
3141
|
}
|
|
2558
3142
|
|
|
2559
|
-
const totalMs =
|
|
3143
|
+
const totalMs = getPreviewDurationMs();
|
|
2560
3144
|
if (totalMs > 0) {
|
|
2561
3145
|
const pct = (targetMs / totalMs) * 100;
|
|
2562
3146
|
timelineProgress.style.width = pct + '%';
|
|
@@ -2652,8 +3236,9 @@ async function regenClip(sceneName, btn) {
|
|
|
2652
3236
|
setStatus('Regenerating TTS for ' + sceneName + '...', 'saving');
|
|
2653
3237
|
|
|
2654
3238
|
try {
|
|
2655
|
-
// Save current voiceover state first
|
|
3239
|
+
// Save current voiceover + timing state first (new scenes need timing marks)
|
|
2656
3240
|
await saveVoiceover();
|
|
3241
|
+
await saveTiming();
|
|
2657
3242
|
|
|
2658
3243
|
const resp = await fetch('/api/regen-clip', {
|
|
2659
3244
|
method: 'POST',
|
|
@@ -2688,6 +3273,69 @@ async function regenClip(sceneName, btn) {
|
|
|
2688
3273
|
}
|
|
2689
3274
|
}
|
|
2690
3275
|
|
|
3276
|
+
// Sync DOM form values back to in-memory scenes array.
|
|
3277
|
+
// Called before renderSceneList() to preserve user edits.
|
|
3278
|
+
function syncFormValuesToScenes() {
|
|
3279
|
+
for (const s of scenes) {
|
|
3280
|
+
const textEl = document.querySelector('textarea[data-scene="' + s.name + '"][data-field="text"]');
|
|
3281
|
+
if (textEl && textEl.value) {
|
|
3282
|
+
if (!s.vo) s.vo = { scene: s.name, text: '' };
|
|
3283
|
+
s.vo.text = textEl.value;
|
|
3284
|
+
}
|
|
3285
|
+
const voiceEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="voice"]');
|
|
3286
|
+
if (voiceEl?.value && s.vo) s.vo.voice = voiceEl.value;
|
|
3287
|
+
const speedEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="speed"]');
|
|
3288
|
+
if (speedEl?.value && s.vo) s.vo.speed = parseFloat(speedEl.value);
|
|
3289
|
+
}
|
|
3290
|
+
syncOverlayFormValuesToScenes();
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
function syncOverlayFormValuesForScene(sceneName) {
|
|
3294
|
+
const s = scenes.find(sc => sc.name === sceneName);
|
|
3295
|
+
if (!s) return;
|
|
3296
|
+
const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
|
|
3297
|
+
if (!card) return;
|
|
3298
|
+
|
|
3299
|
+
const type = card.querySelector('[data-field="overlay-type"]')?.value ?? '';
|
|
3300
|
+
if (!type) {
|
|
3301
|
+
s.overlay = undefined;
|
|
3302
|
+
return;
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
const next = { ...(s.overlay ?? {}), type };
|
|
3306
|
+
next.placement = card.querySelector('[data-field="overlay-placement"]')?.value ?? 'bottom-center';
|
|
3307
|
+
|
|
3308
|
+
const motion = card.querySelector('[data-field="overlay-motion"]')?.value ?? 'none';
|
|
3309
|
+
if (motion && motion !== 'none') next.motion = motion;
|
|
3310
|
+
else delete next.motion;
|
|
3311
|
+
|
|
3312
|
+
delete next.text;
|
|
3313
|
+
delete next.title;
|
|
3314
|
+
delete next.body;
|
|
3315
|
+
delete next.kicker;
|
|
3316
|
+
delete next.src;
|
|
3317
|
+
|
|
3318
|
+
const text = card.querySelector('[data-field="overlay-text"]')?.value ?? '';
|
|
3319
|
+
const body = card.querySelector('[data-field="overlay-body"]')?.value ?? '';
|
|
3320
|
+
const kicker = card.querySelector('[data-field="overlay-kicker"]')?.value ?? '';
|
|
3321
|
+
const src = card.querySelector('[data-field="overlay-src"]')?.value ?? '';
|
|
3322
|
+
|
|
3323
|
+
if (type === 'lower-third' || type === 'callout') {
|
|
3324
|
+
next.text = text;
|
|
3325
|
+
} else {
|
|
3326
|
+
next.title = text;
|
|
3327
|
+
if (body) next.body = body;
|
|
3328
|
+
if (type === 'headline-card' && kicker) next.kicker = kicker;
|
|
3329
|
+
if (type === 'image-card' && src) next.src = src;
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
s.overlay = next;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
function syncOverlayFormValuesToScenes() {
|
|
3336
|
+
for (const s of scenes) syncOverlayFormValuesForScene(s.name);
|
|
3337
|
+
}
|
|
3338
|
+
|
|
2691
3339
|
function collectVoiceover() {
|
|
2692
3340
|
return scenes.map(s => {
|
|
2693
3341
|
const textEl = document.querySelector('textarea[data-scene="' + s.name + '"][data-field="text"]');
|
|
@@ -2706,43 +3354,14 @@ function collectVoiceover() {
|
|
|
2706
3354
|
}
|
|
2707
3355
|
|
|
2708
3356
|
function collectOverlays() {
|
|
3357
|
+
// Serialize from in-memory scene state only.
|
|
3358
|
+
// Per-scene sync happens in wireOverlayListeners on each field change.
|
|
3359
|
+
// Do NOT call syncOverlayFormValuesToScenes() here — it re-reads ALL
|
|
3360
|
+
// scene cards from the DOM which can overwrite good in-memory data
|
|
3361
|
+
// with stale DOM values from collapsed/unedited cards.
|
|
2709
3362
|
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);
|
|
3363
|
+
.filter(s => s.overlay?.type)
|
|
3364
|
+
.map(s => ({ ...s.overlay, scene: s.name }));
|
|
2746
3365
|
}
|
|
2747
3366
|
|
|
2748
3367
|
async function saveVoiceover() {
|
|
@@ -2942,6 +3561,7 @@ document.getElementById('btn-save').addEventListener('click', async () => {
|
|
|
2942
3561
|
await saveVoiceover();
|
|
2943
3562
|
await saveOverlays();
|
|
2944
3563
|
await saveEffects();
|
|
3564
|
+
await saveTiming();
|
|
2945
3565
|
clearDirty();
|
|
2946
3566
|
setStatus('All changes saved', 'saved');
|
|
2947
3567
|
saveBtn.textContent = '\\u2713 Saved';
|
|
@@ -2959,13 +3579,12 @@ document.getElementById('btn-save').addEventListener('click', async () => {
|
|
|
2959
3579
|
|
|
2960
3580
|
// Export button (re-align audio + export MP4, no re-recording)
|
|
2961
3581
|
document.getElementById('btn-export').addEventListener('click', async () => {
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
}
|
|
3582
|
+
// Always save before export to ensure timing marks + voiceover are persisted
|
|
3583
|
+
await saveVoiceover();
|
|
3584
|
+
await saveOverlays();
|
|
3585
|
+
await saveEffects();
|
|
3586
|
+
await saveTiming();
|
|
3587
|
+
clearDirty();
|
|
2969
3588
|
const overlay = document.getElementById('recording-overlay');
|
|
2970
3589
|
const title = document.getElementById('recording-title');
|
|
2971
3590
|
const subtitle = document.getElementById('recording-subtitle');
|
|
@@ -3008,6 +3627,7 @@ document.getElementById('btn-rerecord').addEventListener('click', async () => {
|
|
|
3008
3627
|
await saveVoiceover();
|
|
3009
3628
|
await saveOverlays();
|
|
3010
3629
|
await saveEffects();
|
|
3630
|
+
await saveTiming();
|
|
3011
3631
|
clearDirty();
|
|
3012
3632
|
}
|
|
3013
3633
|
const overlay = document.getElementById('recording-overlay');
|