@argo-video/cli 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -33
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -1
- package/dist/cli.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +29 -47
- package/dist/init.js.map +1 -1
- package/dist/overlays/index.d.ts +7 -2
- package/dist/overlays/index.d.ts.map +1 -1
- package/dist/overlays/index.js +49 -4
- package/dist/overlays/index.js.map +1 -1
- package/dist/overlays/manifest-loader.d.ts +4 -0
- package/dist/overlays/manifest-loader.d.ts.map +1 -0
- package/dist/overlays/manifest-loader.js +22 -0
- package/dist/overlays/manifest-loader.js.map +1 -0
- package/dist/overlays/manifest.d.ts.map +1 -1
- package/dist/overlays/manifest.js +4 -19
- package/dist/overlays/manifest.js.map +1 -1
- package/dist/overlays/types.d.ts +10 -0
- package/dist/overlays/types.d.ts.map +1 -1
- package/dist/overlays/types.js.map +1 -1
- package/dist/parse-playwright.d.ts +15 -0
- package/dist/parse-playwright.d.ts.map +1 -1
- package/dist/parse-playwright.js +17 -0
- package/dist/parse-playwright.js.map +1 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +36 -3
- package/dist/pipeline.js.map +1 -1
- package/dist/preview.d.ts +29 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +2137 -0
- package/dist/preview.js.map +1 -0
- package/dist/record.d.ts.map +1 -1
- package/dist/record.js +12 -4
- package/dist/record.js.map +1 -1
- package/dist/tts/engine.d.ts +8 -0
- package/dist/tts/engine.d.ts.map +1 -1
- package/dist/tts/engine.js.map +1 -1
- package/dist/tts/engines/elevenlabs.d.ts +2 -1
- package/dist/tts/engines/elevenlabs.d.ts.map +1 -1
- package/dist/tts/engines/elevenlabs.js +3 -0
- package/dist/tts/engines/elevenlabs.js.map +1 -1
- package/dist/tts/engines/gemini.d.ts +2 -1
- package/dist/tts/engines/gemini.d.ts.map +1 -1
- package/dist/tts/engines/gemini.js +3 -0
- package/dist/tts/engines/gemini.js.map +1 -1
- package/dist/tts/engines/kokoro.d.ts +2 -1
- package/dist/tts/engines/kokoro.d.ts.map +1 -1
- package/dist/tts/engines/kokoro.js +3 -0
- package/dist/tts/engines/kokoro.js.map +1 -1
- package/dist/tts/engines/mlx-audio.d.ts +2 -1
- package/dist/tts/engines/mlx-audio.d.ts.map +1 -1
- package/dist/tts/engines/mlx-audio.js +6 -0
- package/dist/tts/engines/mlx-audio.js.map +1 -1
- package/dist/tts/engines/openai.d.ts +7 -2
- package/dist/tts/engines/openai.d.ts.map +1 -1
- package/dist/tts/engines/openai.js +13 -2
- package/dist/tts/engines/openai.js.map +1 -1
- package/dist/tts/engines/sarvam.d.ts +2 -1
- package/dist/tts/engines/sarvam.d.ts.map +1 -1
- package/dist/tts/engines/sarvam.js +3 -0
- package/dist/tts/engines/sarvam.js.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +36 -54
- package/dist/validate.js.map +1 -1
- package/package.json +1 -1
- package/scripts/setup-mlx-audio.sh +1 -1
- package/scripts/voice-clone-preview.sh +1 -1
package/dist/preview.js
ADDED
|
@@ -0,0 +1,2137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* argo preview — browser-based replay viewer for iterating on voiceover,
|
|
3
|
+
* overlays, and timing without re-recording.
|
|
4
|
+
*
|
|
5
|
+
* Serves a local web page that plays a seekable preview video (preferring MP4
|
|
6
|
+
* over the raw Playwright WebM), overlays audio clips at scene timestamps,
|
|
7
|
+
* renders overlay cues on a DOM layer, and lets the user edit voiceover text
|
|
8
|
+
* + overlay props inline with per-scene TTS regen.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from 'node:child_process';
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { readFileSync, existsSync, readdirSync, writeFileSync, statSync, createReadStream } from 'node:fs';
|
|
13
|
+
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
14
|
+
import { renderTemplate } from './overlays/templates.js';
|
|
15
|
+
import { alignClips, schedulePlacements } from './tts/align.js';
|
|
16
|
+
import { ClipCache } from './tts/cache.js';
|
|
17
|
+
import { createWavBuffer, parseWavHeader } from './tts/engine.js';
|
|
18
|
+
const MIME_TYPES = {
|
|
19
|
+
'.html': 'text/html',
|
|
20
|
+
'.js': 'text/javascript',
|
|
21
|
+
'.css': 'text/css',
|
|
22
|
+
'.json': 'application/json',
|
|
23
|
+
'.webm': 'video/webm',
|
|
24
|
+
'.wav': 'audio/wav',
|
|
25
|
+
'.mp4': 'video/mp4',
|
|
26
|
+
'.zip': 'application/zip',
|
|
27
|
+
};
|
|
28
|
+
function readJsonFile(filePath, fallback) {
|
|
29
|
+
if (!existsSync(filePath))
|
|
30
|
+
return fallback;
|
|
31
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
32
|
+
}
|
|
33
|
+
function setManifestField(target, key, value) {
|
|
34
|
+
if (value === undefined || value === null || value === '') {
|
|
35
|
+
if (key in target) {
|
|
36
|
+
delete target[key];
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (target[key] === value)
|
|
42
|
+
return false;
|
|
43
|
+
target[key] = value;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
function updatePreviewVoiceoverEntry(target, entry) {
|
|
47
|
+
let changed = false;
|
|
48
|
+
changed = setManifestField(target, 'text', entry.text) || changed;
|
|
49
|
+
changed = setManifestField(target, 'voice', entry.voice) || changed;
|
|
50
|
+
changed = setManifestField(target, 'speed', entry.speed) || changed;
|
|
51
|
+
changed = setManifestField(target, 'lang', entry.lang) || changed;
|
|
52
|
+
changed = setManifestField(target, '_hint', entry._hint) || changed;
|
|
53
|
+
return changed;
|
|
54
|
+
}
|
|
55
|
+
function updatePreviewOverlayEntry(target, overlay) {
|
|
56
|
+
if (!overlay) {
|
|
57
|
+
if ('overlay' in target) {
|
|
58
|
+
delete target.overlay;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const overlayTarget = (target.overlay && typeof target.overlay === 'object')
|
|
64
|
+
? target.overlay
|
|
65
|
+
: (target.overlay = {});
|
|
66
|
+
let changed = false;
|
|
67
|
+
changed = setManifestField(overlayTarget, 'type', overlay.type) || changed;
|
|
68
|
+
changed = setManifestField(overlayTarget, 'motion', overlay.motion) || changed;
|
|
69
|
+
changed = setManifestField(overlayTarget, 'autoBackground', overlay.autoBackground) || changed;
|
|
70
|
+
if (overlay.placement === 'bottom-center') {
|
|
71
|
+
if (overlayTarget.placement !== undefined && overlayTarget.placement !== 'bottom-center') {
|
|
72
|
+
delete overlayTarget.placement;
|
|
73
|
+
changed = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
changed = setManifestField(overlayTarget, 'placement', overlay.placement) || changed;
|
|
78
|
+
}
|
|
79
|
+
if (overlay.type === 'lower-third' || overlay.type === 'callout') {
|
|
80
|
+
changed = setManifestField(overlayTarget, 'text', overlay.text) || changed;
|
|
81
|
+
changed = setManifestField(overlayTarget, 'title', undefined) || changed;
|
|
82
|
+
changed = setManifestField(overlayTarget, 'body', undefined) || changed;
|
|
83
|
+
changed = setManifestField(overlayTarget, 'kicker', undefined) || changed;
|
|
84
|
+
changed = setManifestField(overlayTarget, 'src', undefined) || changed;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
changed = setManifestField(overlayTarget, 'text', undefined) || changed;
|
|
88
|
+
changed = setManifestField(overlayTarget, 'title', 'title' in overlay ? overlay.title : undefined) || changed;
|
|
89
|
+
changed = setManifestField(overlayTarget, 'body', 'body' in overlay ? overlay.body : undefined) || changed;
|
|
90
|
+
changed = setManifestField(overlayTarget, 'kicker', 'kicker' in overlay ? overlay.kicker : undefined) || changed;
|
|
91
|
+
changed = setManifestField(overlayTarget, 'src', 'src' in overlay ? overlay.src : undefined) || changed;
|
|
92
|
+
}
|
|
93
|
+
return changed;
|
|
94
|
+
}
|
|
95
|
+
function buildRenderedOverlays(overlays) {
|
|
96
|
+
const renderedOverlays = {};
|
|
97
|
+
for (const entry of overlays) {
|
|
98
|
+
const { scene, ...cue } = entry;
|
|
99
|
+
const zone = cue.placement ?? 'bottom-center';
|
|
100
|
+
const { contentHtml, styles } = renderTemplate(cue, 'dark');
|
|
101
|
+
renderedOverlays[scene] = { html: contentHtml, styles, zone };
|
|
102
|
+
}
|
|
103
|
+
return renderedOverlays;
|
|
104
|
+
}
|
|
105
|
+
function buildPreviewSceneReport(timing, sceneDurations, persisted) {
|
|
106
|
+
const scheduled = Object.entries(timing)
|
|
107
|
+
.filter(([scene]) => sceneDurations[scene] && sceneDurations[scene] > 0)
|
|
108
|
+
.map(([scene, startMs]) => ({ scene, startMs, durationMs: sceneDurations[scene] }));
|
|
109
|
+
if (scheduled.length === 0)
|
|
110
|
+
return null;
|
|
111
|
+
const placements = schedulePlacements(scheduled);
|
|
112
|
+
return createSceneReportFromPlacements(placements, persisted);
|
|
113
|
+
}
|
|
114
|
+
function createSceneReportFromPlacements(placements, persisted) {
|
|
115
|
+
const lastEndMs = placements.length > 0 ? placements[placements.length - 1].endMs : 0;
|
|
116
|
+
const baseDurationMs = persisted?.totalDurationMs ?? lastEndMs;
|
|
117
|
+
return {
|
|
118
|
+
totalDurationMs: Math.max(baseDurationMs, lastEndMs),
|
|
119
|
+
overflowMs: Math.max(persisted?.overflowMs ?? 0, lastEndMs - baseDurationMs),
|
|
120
|
+
scenes: placements.map((placement) => ({
|
|
121
|
+
scene: placement.scene,
|
|
122
|
+
startMs: placement.startMs,
|
|
123
|
+
endMs: placement.endMs,
|
|
124
|
+
durationMs: placement.endMs - placement.startMs,
|
|
125
|
+
})),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function loadPreviewData(demoName, argoDir, demosDir) {
|
|
129
|
+
const demoDir = join(argoDir, demoName);
|
|
130
|
+
// Required files
|
|
131
|
+
const timingPath = join(demoDir, '.timing.json');
|
|
132
|
+
if (!existsSync(timingPath)) {
|
|
133
|
+
throw new Error(`No timing data found at ${timingPath}. Run 'argo pipeline ${demoName}' first.`);
|
|
134
|
+
}
|
|
135
|
+
const timing = readJsonFile(timingPath, {});
|
|
136
|
+
// Unified scenes manifest
|
|
137
|
+
const scenesPath = join(demosDir, `${demoName}.scenes.json`);
|
|
138
|
+
const scenes = readJsonFile(scenesPath, []);
|
|
139
|
+
// Derive voiceover and overlay arrays from unified entries
|
|
140
|
+
const voiceover = scenes.map((s) => ({
|
|
141
|
+
scene: s.scene,
|
|
142
|
+
text: s.text,
|
|
143
|
+
voice: s.voice,
|
|
144
|
+
speed: s.speed,
|
|
145
|
+
lang: s.lang,
|
|
146
|
+
_hint: s._hint,
|
|
147
|
+
}));
|
|
148
|
+
const overlays = scenes
|
|
149
|
+
.filter((s) => s.overlay)
|
|
150
|
+
.map((s) => ({ scene: s.scene, ...s.overlay }));
|
|
151
|
+
// Scene durations
|
|
152
|
+
const sdPath = join(demoDir, '.scene-durations.json');
|
|
153
|
+
const sceneDurations = readJsonFile(sdPath, {});
|
|
154
|
+
// Use persisted report metadata, but derive scene placements from the current
|
|
155
|
+
// timing + scene durations so preview stays in sync after per-scene regen.
|
|
156
|
+
const reportPath = join(demoDir, 'scene-report.json');
|
|
157
|
+
const persistedReport = readJsonFile(reportPath, null);
|
|
158
|
+
const sceneReport = buildPreviewSceneReport(timing, sceneDurations, persistedReport);
|
|
159
|
+
const renderedOverlays = buildRenderedOverlays(overlays);
|
|
160
|
+
// Pipeline metadata (from last recording)
|
|
161
|
+
const projectRoot = dirname(resolve(argoDir));
|
|
162
|
+
const metaCandidates = ['videos', 'output'].map(d => join(projectRoot, d, `${demoName}.meta.json`));
|
|
163
|
+
const metaPath = metaCandidates.find(p => existsSync(p));
|
|
164
|
+
const pipelineMeta = metaPath ? readJsonFile(metaPath, {}) : null;
|
|
165
|
+
return { demoName, timing, voiceover, overlays, sceneDurations, sceneReport, renderedOverlays, pipelineMeta };
|
|
166
|
+
}
|
|
167
|
+
/** List WAV clip files available for a demo. */
|
|
168
|
+
function listClips(argoDir, demoName) {
|
|
169
|
+
const clipsDir = join(argoDir, demoName, 'clips');
|
|
170
|
+
if (!existsSync(clipsDir))
|
|
171
|
+
return [];
|
|
172
|
+
return readdirSync(clipsDir).filter((f) => f.endsWith('.wav'));
|
|
173
|
+
}
|
|
174
|
+
function getPreviewHtml(data) {
|
|
175
|
+
return PREVIEW_HTML.replace('__PREVIEW_DATA__', JSON.stringify(data));
|
|
176
|
+
}
|
|
177
|
+
function resolveClipPath(clipsDir, clipFile) {
|
|
178
|
+
const decoded = decodeURIComponent(clipFile);
|
|
179
|
+
const candidate = resolve(clipsDir, decoded);
|
|
180
|
+
const rel = relative(clipsDir, candidate);
|
|
181
|
+
if (rel.startsWith('..') || rel.includes(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return candidate;
|
|
185
|
+
}
|
|
186
|
+
function readClipInfo(clipPath, scene) {
|
|
187
|
+
const wavBuf = readFileSync(clipPath);
|
|
188
|
+
const header = parseWavHeader(wavBuf);
|
|
189
|
+
const sampleCount = header.dataSize / 4;
|
|
190
|
+
const samples = new Float32Array(sampleCount);
|
|
191
|
+
for (let i = 0; i < sampleCount && header.dataOffset + i * 4 + 3 < wavBuf.length; i++) {
|
|
192
|
+
samples[i] = wavBuf.readFloatLE(header.dataOffset + i * 4);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
scene,
|
|
196
|
+
durationMs: header.durationMs,
|
|
197
|
+
samples,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, defaults) {
|
|
201
|
+
const demoDir = join(argoDir, demoName);
|
|
202
|
+
const scenesPath = join(demosDir, `${demoName}.scenes.json`);
|
|
203
|
+
const timingPath = join(demoDir, '.timing.json');
|
|
204
|
+
const persistedReportPath = join(demoDir, 'scene-report.json');
|
|
205
|
+
const projectRoot = dirname(resolve(argoDir));
|
|
206
|
+
const cache = new ClipCache(projectRoot);
|
|
207
|
+
const timing = readJsonFile(timingPath, {});
|
|
208
|
+
const persistedReport = readJsonFile(persistedReportPath, null);
|
|
209
|
+
const scenesRaw = readJsonFile(scenesPath, []);
|
|
210
|
+
const manifest = scenesRaw.map((s) => ({
|
|
211
|
+
scene: s.scene,
|
|
212
|
+
text: s.text,
|
|
213
|
+
voice: s.voice,
|
|
214
|
+
speed: s.speed,
|
|
215
|
+
lang: s.lang,
|
|
216
|
+
_hint: s._hint,
|
|
217
|
+
}));
|
|
218
|
+
const clips = [];
|
|
219
|
+
const sceneDurations = {};
|
|
220
|
+
for (const entry of manifest) {
|
|
221
|
+
const cacheEntry = {
|
|
222
|
+
scene: entry.scene,
|
|
223
|
+
text: entry.text,
|
|
224
|
+
voice: entry.voice ?? defaults?.voice,
|
|
225
|
+
speed: entry.speed ?? defaults?.speed,
|
|
226
|
+
lang: entry.lang,
|
|
227
|
+
};
|
|
228
|
+
const clipPath = cache.getClipPath(demoName, cacheEntry);
|
|
229
|
+
if (!existsSync(clipPath)) {
|
|
230
|
+
throw new Error(`Expected regenerated clip for scene "${entry.scene}" at ${clipPath}, but it was not found. ` +
|
|
231
|
+
`Try running: argo tts generate ${scenesPath}`);
|
|
232
|
+
}
|
|
233
|
+
const clipInfo = readClipInfo(clipPath, entry.scene);
|
|
234
|
+
clips.push(clipInfo);
|
|
235
|
+
sceneDurations[entry.scene] = clipInfo.durationMs;
|
|
236
|
+
}
|
|
237
|
+
writeFileSync(join(demoDir, '.scene-durations.json'), JSON.stringify(sceneDurations, null, 2), 'utf-8');
|
|
238
|
+
const baseReport = buildPreviewSceneReport(timing, sceneDurations, persistedReport);
|
|
239
|
+
const totalDurationMs = baseReport?.totalDurationMs ?? 0;
|
|
240
|
+
const aligned = alignClips(timing, clips, totalDurationMs);
|
|
241
|
+
writeFileSync(join(demoDir, 'narration-aligned.wav'), createWavBuffer(aligned.samples, 24_000));
|
|
242
|
+
return {
|
|
243
|
+
sceneDurations,
|
|
244
|
+
sceneReport: createSceneReportFromPlacements(aligned.placements, persistedReport),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
async function runPreviewTtsGenerate(manifestPath) {
|
|
248
|
+
await new Promise((resolve, reject) => {
|
|
249
|
+
execFile('npx', ['argo', 'tts', 'generate', manifestPath], {
|
|
250
|
+
env: process.env,
|
|
251
|
+
}, (err, stdout, stderr) => {
|
|
252
|
+
if (err) {
|
|
253
|
+
reject(new Error(`TTS regen failed: ${stderr || stdout}`));
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
resolve();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
export async function startPreviewServer(options) {
|
|
262
|
+
const argoDir = options.argoDir ?? '.argo';
|
|
263
|
+
const demosDir = options.demosDir ?? 'demos';
|
|
264
|
+
const port = options.port ?? 0; // 0 = auto-assign
|
|
265
|
+
const demoName = options.demoName;
|
|
266
|
+
const demoDir = join(argoDir, demoName);
|
|
267
|
+
// Prefer exported MP4 (has keyframes for seeking) over raw WebM (no cue points)
|
|
268
|
+
const webmPath = join(demoDir, 'video.webm');
|
|
269
|
+
const projectRoot = dirname(resolve(argoDir));
|
|
270
|
+
const mp4Candidates = ['videos', 'output'].map(d => join(projectRoot, d, `${demoName}.mp4`));
|
|
271
|
+
const videoPath = mp4Candidates.find(p => existsSync(p)) ?? webmPath;
|
|
272
|
+
if (!existsSync(videoPath)) {
|
|
273
|
+
throw new Error(`No recording found for '${demoName}'. Run 'argo pipeline ${demoName}' first.`);
|
|
274
|
+
}
|
|
275
|
+
const videoMime = videoPath.endsWith('.mp4') ? 'video/mp4' : 'video/webm';
|
|
276
|
+
const server = createServer(async (req, res) => {
|
|
277
|
+
const url = req.url ?? '/';
|
|
278
|
+
try {
|
|
279
|
+
// --- API routes ---
|
|
280
|
+
if (url === '/api/data') {
|
|
281
|
+
const data = loadPreviewData(demoName, argoDir, demosDir);
|
|
282
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
283
|
+
res.end(JSON.stringify(data));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (url === '/api/clips') {
|
|
287
|
+
const clips = listClips(argoDir, demoName);
|
|
288
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
289
|
+
res.end(JSON.stringify(clips));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// Save voiceover fields into unified .scenes.json
|
|
293
|
+
if (url === '/api/voiceover' && req.method === 'POST') {
|
|
294
|
+
const chunks = [];
|
|
295
|
+
for await (const chunk of req)
|
|
296
|
+
chunks.push(chunk);
|
|
297
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
298
|
+
const scenesPath = join(demosDir, `${demoName}.scenes.json`);
|
|
299
|
+
const scenes = readJsonFile(scenesPath, []);
|
|
300
|
+
let changed = false;
|
|
301
|
+
for (const vo of body) {
|
|
302
|
+
const existing = scenes.find((s) => s.scene === vo.scene);
|
|
303
|
+
if (existing) {
|
|
304
|
+
changed = updatePreviewVoiceoverEntry(existing, vo) || changed;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (changed) {
|
|
308
|
+
writeFileSync(scenesPath, JSON.stringify(scenes, null, 2) + '\n', 'utf-8');
|
|
309
|
+
}
|
|
310
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
311
|
+
res.end(JSON.stringify({ ok: true, changed }));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
// Render overlay templates without saving to disk (for live preview)
|
|
315
|
+
if (url === '/api/render-overlays' && req.method === 'POST') {
|
|
316
|
+
const chunks = [];
|
|
317
|
+
for await (const chunk of req)
|
|
318
|
+
chunks.push(chunk);
|
|
319
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
320
|
+
const renderedOverlays = buildRenderedOverlays(body);
|
|
321
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
322
|
+
res.end(JSON.stringify({ ok: true, renderedOverlays }));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// Save overlay fields into unified .scenes.json
|
|
326
|
+
if (url === '/api/overlays' && req.method === 'POST') {
|
|
327
|
+
const chunks = [];
|
|
328
|
+
for await (const chunk of req)
|
|
329
|
+
chunks.push(chunk);
|
|
330
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
331
|
+
const scenesPath = join(demosDir, `${demoName}.scenes.json`);
|
|
332
|
+
const scenes = readJsonFile(scenesPath, []);
|
|
333
|
+
// Build a map of posted overlays keyed by scene
|
|
334
|
+
const ovByScene = new Map();
|
|
335
|
+
for (const ov of body)
|
|
336
|
+
ovByScene.set(ov.scene, ov);
|
|
337
|
+
let changed = false;
|
|
338
|
+
for (const entry of scenes) {
|
|
339
|
+
const posted = ovByScene.get(entry.scene);
|
|
340
|
+
changed = updatePreviewOverlayEntry(entry, posted) || changed;
|
|
341
|
+
}
|
|
342
|
+
if (changed) {
|
|
343
|
+
writeFileSync(scenesPath, JSON.stringify(scenes, null, 2) + '\n', 'utf-8');
|
|
344
|
+
}
|
|
345
|
+
// Reload and re-render overlays
|
|
346
|
+
const data = loadPreviewData(demoName, argoDir, demosDir);
|
|
347
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
348
|
+
res.end(JSON.stringify({ ok: true, changed, renderedOverlays: data.renderedOverlays }));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Regenerate a single TTS clip
|
|
352
|
+
if (url === '/api/regen-clip' && req.method === 'POST') {
|
|
353
|
+
const chunks = [];
|
|
354
|
+
for await (const chunk of req)
|
|
355
|
+
chunks.push(chunk);
|
|
356
|
+
const { scene } = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
357
|
+
const manifestPath = join(demosDir, `${demoName}.scenes.json`);
|
|
358
|
+
const regenerateTts = options.regenerateTts ?? ((args) => runPreviewTtsGenerate(args.manifestPath));
|
|
359
|
+
await regenerateTts({ manifestPath, scene });
|
|
360
|
+
const refreshed = refreshPreviewAudioArtifacts(demoName, argoDir, demosDir, options.ttsDefaults);
|
|
361
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
362
|
+
res.end(JSON.stringify({
|
|
363
|
+
ok: true,
|
|
364
|
+
scene,
|
|
365
|
+
durationMs: refreshed.sceneDurations[scene] ?? 0,
|
|
366
|
+
sceneDurations: refreshed.sceneDurations,
|
|
367
|
+
sceneReport: refreshed.sceneReport,
|
|
368
|
+
}));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Re-record: run the full pipeline
|
|
372
|
+
if (url === '/api/rerecord' && req.method === 'POST') {
|
|
373
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' });
|
|
374
|
+
try {
|
|
375
|
+
await new Promise((resolve, reject) => {
|
|
376
|
+
const child = execFile('npx', ['argo', 'pipeline', demoName], {
|
|
377
|
+
env: process.env,
|
|
378
|
+
}, (err, stdout, stderr) => {
|
|
379
|
+
if (err)
|
|
380
|
+
reject(new Error(stderr || stdout || err.message));
|
|
381
|
+
else
|
|
382
|
+
resolve();
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
res.end(JSON.stringify({ ok: true }));
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
// --- Static file serving ---
|
|
393
|
+
// Serve video with Range request support (required for seeking)
|
|
394
|
+
if (url === '/video' || url === '/video.webm') {
|
|
395
|
+
serveFileWithRanges(req, res, videoPath, videoMime);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// Serve narration-aligned.wav
|
|
399
|
+
if (url === '/narration-aligned.wav') {
|
|
400
|
+
serveFile(res, join(demoDir, 'narration-aligned.wav'));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
// Serve individual clips: /clips/scene-name.wav
|
|
404
|
+
if (url.startsWith('/clips/')) {
|
|
405
|
+
const clipFile = url.slice('/clips/'.length);
|
|
406
|
+
const clipsDir = join(demoDir, 'clips');
|
|
407
|
+
const clipPath = resolveClipPath(clipsDir, clipFile);
|
|
408
|
+
if (clipPath && existsSync(clipPath)) {
|
|
409
|
+
serveFile(res, clipPath);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
res.writeHead(404);
|
|
413
|
+
res.end('Clip not found');
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
// Serve trace.zip (for Playwright trace viewer link)
|
|
418
|
+
if (url === '/trace.zip') {
|
|
419
|
+
const tracePath = join(demoDir, 'trace.zip');
|
|
420
|
+
if (existsSync(tracePath)) {
|
|
421
|
+
serveFile(res, tracePath);
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
res.writeHead(404);
|
|
425
|
+
res.end('No trace captured');
|
|
426
|
+
}
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// Root — serve the preview HTML
|
|
430
|
+
if (url === '/' || url === '/index.html') {
|
|
431
|
+
const data = loadPreviewData(demoName, argoDir, demosDir);
|
|
432
|
+
const html = getPreviewHtml(data);
|
|
433
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
434
|
+
res.end(html);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
res.writeHead(404);
|
|
438
|
+
res.end('Not found');
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
442
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
return new Promise((resolve, reject) => {
|
|
446
|
+
server.once('error', reject);
|
|
447
|
+
server.listen(port, '127.0.0.1', () => {
|
|
448
|
+
server.off('error', reject);
|
|
449
|
+
const addr = server.address();
|
|
450
|
+
const assignedPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
451
|
+
const serverUrl = `http://127.0.0.1:${assignedPort}`;
|
|
452
|
+
resolve({
|
|
453
|
+
url: serverUrl,
|
|
454
|
+
close: () => server.close(),
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
function serveFileWithRanges(req, res, filePath, mime) {
|
|
460
|
+
if (!existsSync(filePath)) {
|
|
461
|
+
res.writeHead(404);
|
|
462
|
+
res.end('Not found');
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const stat = statSync(filePath);
|
|
466
|
+
const total = stat.size;
|
|
467
|
+
const range = req.headers.range;
|
|
468
|
+
if (range) {
|
|
469
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
|
|
470
|
+
if (!match) {
|
|
471
|
+
res.writeHead(416, { 'Content-Range': `bytes */${total}` });
|
|
472
|
+
res.end();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
let start;
|
|
476
|
+
let end;
|
|
477
|
+
if (match[1] === '' && match[2] === '') {
|
|
478
|
+
res.writeHead(416, { 'Content-Range': `bytes */${total}` });
|
|
479
|
+
res.end();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (match[1] === '') {
|
|
483
|
+
const suffixLength = Number.parseInt(match[2], 10);
|
|
484
|
+
if (!Number.isFinite(suffixLength) || suffixLength <= 0) {
|
|
485
|
+
res.writeHead(416, { 'Content-Range': `bytes */${total}` });
|
|
486
|
+
res.end();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
start = Math.max(0, total - suffixLength);
|
|
490
|
+
end = total - 1;
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
start = Number.parseInt(match[1], 10);
|
|
494
|
+
end = match[2] ? Number.parseInt(match[2], 10) : total - 1;
|
|
495
|
+
}
|
|
496
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
|
|
497
|
+
res.writeHead(416, { 'Content-Range': `bytes */${total}` });
|
|
498
|
+
res.end();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
end = Math.min(end, total - 1);
|
|
502
|
+
const chunkSize = end - start + 1;
|
|
503
|
+
const stream = createReadStream(filePath, { start, end });
|
|
504
|
+
stream.on('error', () => {
|
|
505
|
+
if (!res.headersSent) {
|
|
506
|
+
res.writeHead(500);
|
|
507
|
+
}
|
|
508
|
+
res.end('Failed to read file');
|
|
509
|
+
});
|
|
510
|
+
res.writeHead(206, {
|
|
511
|
+
'Content-Range': `bytes ${start}-${end}/${total}`,
|
|
512
|
+
'Accept-Ranges': 'bytes',
|
|
513
|
+
'Content-Length': chunkSize,
|
|
514
|
+
'Content-Type': mime,
|
|
515
|
+
});
|
|
516
|
+
stream.pipe(res);
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
res.writeHead(200, {
|
|
520
|
+
'Content-Length': total,
|
|
521
|
+
'Content-Type': mime,
|
|
522
|
+
'Accept-Ranges': 'bytes',
|
|
523
|
+
});
|
|
524
|
+
createReadStream(filePath).pipe(res);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function serveFile(res, filePath) {
|
|
528
|
+
if (!existsSync(filePath)) {
|
|
529
|
+
res.writeHead(404);
|
|
530
|
+
res.end('Not found');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const ext = extname(filePath);
|
|
534
|
+
const mime = MIME_TYPES[ext] ?? 'application/octet-stream';
|
|
535
|
+
const content = readFileSync(filePath);
|
|
536
|
+
res.writeHead(200, { 'Content-Type': mime, 'Content-Length': content.length });
|
|
537
|
+
res.end(content);
|
|
538
|
+
}
|
|
539
|
+
// ─── Inline HTML for the preview viewer ────────────────────────────────────
|
|
540
|
+
const PREVIEW_HTML = `<!DOCTYPE html>
|
|
541
|
+
<html lang="en">
|
|
542
|
+
<head>
|
|
543
|
+
<meta charset="utf-8">
|
|
544
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
545
|
+
<title>Argo Preview</title>
|
|
546
|
+
<style>
|
|
547
|
+
:root {
|
|
548
|
+
--bg: #0c0c0c;
|
|
549
|
+
--surface: #161616;
|
|
550
|
+
--surface2: #1e1e1e;
|
|
551
|
+
--surface3: #262626;
|
|
552
|
+
--border: #2a2a2a;
|
|
553
|
+
--border-subtle: #222;
|
|
554
|
+
--text: #e8e8e8;
|
|
555
|
+
--text-muted: #777;
|
|
556
|
+
--text-dim: #555;
|
|
557
|
+
--accent: #6366f1;
|
|
558
|
+
--accent-hover: #818cf8;
|
|
559
|
+
--accent-glow: rgba(99,102,241,0.15);
|
|
560
|
+
--accent-glow-strong: rgba(99,102,241,0.3);
|
|
561
|
+
--success: #22c55e;
|
|
562
|
+
--success-glow: rgba(34,197,94,0.15);
|
|
563
|
+
--warning: #f59e0b;
|
|
564
|
+
--error: #ef4444;
|
|
565
|
+
--mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
|
566
|
+
--sans: system-ui, -apple-system, sans-serif;
|
|
567
|
+
--radius: 6px;
|
|
568
|
+
--transition: 0.15s ease;
|
|
569
|
+
}
|
|
570
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
571
|
+
body {
|
|
572
|
+
font-family: var(--sans);
|
|
573
|
+
background: var(--bg);
|
|
574
|
+
color: var(--text);
|
|
575
|
+
height: 100vh;
|
|
576
|
+
display: grid;
|
|
577
|
+
grid-template-columns: 1fr 380px;
|
|
578
|
+
grid-template-rows: auto 1fr;
|
|
579
|
+
gap: 0;
|
|
580
|
+
overflow: hidden;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* Header */
|
|
584
|
+
header {
|
|
585
|
+
grid-column: 1 / -1;
|
|
586
|
+
display: flex;
|
|
587
|
+
align-items: center;
|
|
588
|
+
gap: 16px;
|
|
589
|
+
padding: 12px 20px;
|
|
590
|
+
background: var(--surface);
|
|
591
|
+
border-bottom: 1px solid var(--border);
|
|
592
|
+
}
|
|
593
|
+
header h1 { font-size: 16px; font-weight: 600; }
|
|
594
|
+
header .demo-name { color: var(--accent); }
|
|
595
|
+
header .actions { margin-left: auto; display: flex; gap: 8px; }
|
|
596
|
+
header .trace-link {
|
|
597
|
+
font-size: 12px;
|
|
598
|
+
color: var(--text-muted);
|
|
599
|
+
text-decoration: none;
|
|
600
|
+
padding: 4px 10px;
|
|
601
|
+
border: 1px solid var(--border);
|
|
602
|
+
border-radius: 6px;
|
|
603
|
+
}
|
|
604
|
+
header .trace-link:hover { color: var(--text); border-color: var(--text-muted); }
|
|
605
|
+
|
|
606
|
+
/* Toggle switches */
|
|
607
|
+
.toggle-switch {
|
|
608
|
+
position: relative;
|
|
609
|
+
width: 32px;
|
|
610
|
+
height: 18px;
|
|
611
|
+
cursor: pointer;
|
|
612
|
+
flex-shrink: 0;
|
|
613
|
+
}
|
|
614
|
+
.toggle-switch input { display: none; }
|
|
615
|
+
.toggle-switch .slider {
|
|
616
|
+
position: absolute;
|
|
617
|
+
inset: 0;
|
|
618
|
+
background: var(--surface3);
|
|
619
|
+
border-radius: 9px;
|
|
620
|
+
transition: background var(--transition);
|
|
621
|
+
}
|
|
622
|
+
.toggle-switch .slider::after {
|
|
623
|
+
content: '';
|
|
624
|
+
position: absolute;
|
|
625
|
+
top: 2px;
|
|
626
|
+
left: 2px;
|
|
627
|
+
width: 14px;
|
|
628
|
+
height: 14px;
|
|
629
|
+
background: var(--text-muted);
|
|
630
|
+
border-radius: 50%;
|
|
631
|
+
transition: transform var(--transition), background var(--transition);
|
|
632
|
+
}
|
|
633
|
+
.toggle-switch input:checked + .slider { background: var(--accent); }
|
|
634
|
+
.toggle-switch input:checked + .slider::after { transform: translateX(14px); background: white; }
|
|
635
|
+
.toggle-label {
|
|
636
|
+
font-size: 12px;
|
|
637
|
+
color: var(--text-muted);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/* Main viewer */
|
|
641
|
+
.viewer {
|
|
642
|
+
display: flex;
|
|
643
|
+
flex-direction: column;
|
|
644
|
+
overflow: hidden;
|
|
645
|
+
position: relative;
|
|
646
|
+
}
|
|
647
|
+
.video-container {
|
|
648
|
+
flex: 1;
|
|
649
|
+
position: relative;
|
|
650
|
+
background: #000;
|
|
651
|
+
display: flex;
|
|
652
|
+
align-items: center;
|
|
653
|
+
justify-content: center;
|
|
654
|
+
min-height: 0;
|
|
655
|
+
}
|
|
656
|
+
.video-container video {
|
|
657
|
+
max-width: 100%;
|
|
658
|
+
max-height: 100%;
|
|
659
|
+
display: block;
|
|
660
|
+
cursor: pointer;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/* Overlay preview layer — positioned over the video */
|
|
664
|
+
.overlay-layer {
|
|
665
|
+
position: absolute;
|
|
666
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
667
|
+
pointer-events: none;
|
|
668
|
+
}
|
|
669
|
+
.overlay-cue {
|
|
670
|
+
position: absolute;
|
|
671
|
+
z-index: 10;
|
|
672
|
+
pointer-events: none;
|
|
673
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
674
|
+
opacity: 0;
|
|
675
|
+
transition: opacity 0.3s ease;
|
|
676
|
+
}
|
|
677
|
+
.overlay-cue.visible { opacity: 1; }
|
|
678
|
+
.overlay-cue .preview-badge {
|
|
679
|
+
position: absolute;
|
|
680
|
+
top: -8px;
|
|
681
|
+
right: -8px;
|
|
682
|
+
font-family: var(--mono);
|
|
683
|
+
font-size: 9px;
|
|
684
|
+
font-weight: 600;
|
|
685
|
+
letter-spacing: 0.06em;
|
|
686
|
+
color: #fff;
|
|
687
|
+
background: var(--accent);
|
|
688
|
+
padding: 2px 6px;
|
|
689
|
+
border-radius: 3px;
|
|
690
|
+
line-height: 1;
|
|
691
|
+
opacity: 0.85;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/* Zone positioning */
|
|
695
|
+
.overlay-cue[data-zone="bottom-center"] { bottom: 60px; left: 50%; transform: translateX(-50%); }
|
|
696
|
+
.overlay-cue[data-zone="top-left"] { top: 40px; left: 40px; }
|
|
697
|
+
.overlay-cue[data-zone="top-right"] { top: 40px; right: 40px; }
|
|
698
|
+
.overlay-cue[data-zone="bottom-left"] { bottom: 60px; left: 40px; }
|
|
699
|
+
.overlay-cue[data-zone="bottom-right"] { bottom: 60px; right: 40px; }
|
|
700
|
+
.overlay-cue[data-zone="center"] { top: 50%; left: 50%; transform: translate(-50%, -50%); }
|
|
701
|
+
|
|
702
|
+
/* Timeline bar */
|
|
703
|
+
.timeline {
|
|
704
|
+
background: var(--surface);
|
|
705
|
+
border-top: 1px solid var(--border);
|
|
706
|
+
padding: 12px 20px;
|
|
707
|
+
}
|
|
708
|
+
.timeline-bar {
|
|
709
|
+
position: relative;
|
|
710
|
+
height: 32px;
|
|
711
|
+
background: var(--surface2);
|
|
712
|
+
border-radius: 6px;
|
|
713
|
+
cursor: pointer;
|
|
714
|
+
overflow: visible;
|
|
715
|
+
}
|
|
716
|
+
.timeline-progress {
|
|
717
|
+
position: absolute;
|
|
718
|
+
top: 0; left: 0; bottom: 0;
|
|
719
|
+
background: var(--accent);
|
|
720
|
+
border-radius: 6px 0 0 6px;
|
|
721
|
+
opacity: 0.3;
|
|
722
|
+
pointer-events: none;
|
|
723
|
+
}
|
|
724
|
+
.timeline-scene {
|
|
725
|
+
position: absolute;
|
|
726
|
+
top: 0;
|
|
727
|
+
height: 100%;
|
|
728
|
+
display: flex;
|
|
729
|
+
align-items: center;
|
|
730
|
+
padding: 0 8px;
|
|
731
|
+
font-size: 11px;
|
|
732
|
+
font-family: var(--mono);
|
|
733
|
+
font-weight: 500;
|
|
734
|
+
color: var(--text-muted);
|
|
735
|
+
border-left: 2px solid var(--accent);
|
|
736
|
+
cursor: pointer;
|
|
737
|
+
white-space: nowrap;
|
|
738
|
+
overflow: hidden;
|
|
739
|
+
text-overflow: ellipsis;
|
|
740
|
+
background: var(--accent-glow);
|
|
741
|
+
transition: background var(--transition);
|
|
742
|
+
}
|
|
743
|
+
.timeline-scene:nth-child(odd) { background: rgba(99,102,241,0.08); }
|
|
744
|
+
.timeline-scene:nth-child(even) { background: rgba(99,102,241,0.12); }
|
|
745
|
+
.timeline-scene:hover { color: var(--text); background: var(--accent-glow-strong); }
|
|
746
|
+
.timeline-scene.active { color: var(--text); background: var(--accent-glow-strong); }
|
|
747
|
+
.timeline-scene .has-overlay {
|
|
748
|
+
display: inline-block;
|
|
749
|
+
width: 4px;
|
|
750
|
+
height: 4px;
|
|
751
|
+
border-radius: 50%;
|
|
752
|
+
background: var(--accent);
|
|
753
|
+
margin-left: 4px;
|
|
754
|
+
vertical-align: middle;
|
|
755
|
+
}
|
|
756
|
+
.timeline-playhead {
|
|
757
|
+
position: absolute;
|
|
758
|
+
top: 0;
|
|
759
|
+
bottom: 0;
|
|
760
|
+
width: 2px;
|
|
761
|
+
background: var(--text);
|
|
762
|
+
z-index: 5;
|
|
763
|
+
pointer-events: none;
|
|
764
|
+
transition: left 0.05s linear;
|
|
765
|
+
}
|
|
766
|
+
.timeline-time {
|
|
767
|
+
display: flex;
|
|
768
|
+
justify-content: space-between;
|
|
769
|
+
font-size: 11px;
|
|
770
|
+
font-family: var(--mono);
|
|
771
|
+
color: var(--text-muted);
|
|
772
|
+
margin-top: 4px;
|
|
773
|
+
padding: 0 4px;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/* Audio controls */
|
|
777
|
+
.audio-controls {
|
|
778
|
+
display: flex;
|
|
779
|
+
align-items: center;
|
|
780
|
+
gap: 12px;
|
|
781
|
+
margin-top: 8px;
|
|
782
|
+
}
|
|
783
|
+
.audio-controls .toggle-group {
|
|
784
|
+
display: flex;
|
|
785
|
+
align-items: center;
|
|
786
|
+
gap: 6px;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/* Sidebar */
|
|
790
|
+
.sidebar {
|
|
791
|
+
background: var(--surface);
|
|
792
|
+
border-left: 1px solid var(--border);
|
|
793
|
+
overflow-y: auto;
|
|
794
|
+
display: flex;
|
|
795
|
+
flex-direction: column;
|
|
796
|
+
}
|
|
797
|
+
.sidebar-tabs {
|
|
798
|
+
display: flex;
|
|
799
|
+
border-bottom: 1px solid var(--border);
|
|
800
|
+
}
|
|
801
|
+
.sidebar-tab {
|
|
802
|
+
flex: 1;
|
|
803
|
+
padding: 10px 16px;
|
|
804
|
+
font-size: 12px;
|
|
805
|
+
font-weight: 600;
|
|
806
|
+
color: var(--text-muted);
|
|
807
|
+
text-transform: uppercase;
|
|
808
|
+
letter-spacing: 0.05em;
|
|
809
|
+
background: none;
|
|
810
|
+
border: none;
|
|
811
|
+
border-bottom: 2px solid transparent;
|
|
812
|
+
cursor: pointer;
|
|
813
|
+
transition: color var(--transition), border-color var(--transition);
|
|
814
|
+
}
|
|
815
|
+
.sidebar-tab:hover { color: var(--text); }
|
|
816
|
+
.sidebar-tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
|
817
|
+
.sidebar-panel { overflow-y: auto; flex: 1; }
|
|
818
|
+
.scene-card {
|
|
819
|
+
padding: 14px 16px;
|
|
820
|
+
border-bottom: 1px solid var(--border);
|
|
821
|
+
cursor: pointer;
|
|
822
|
+
transition: background var(--transition), border-color var(--transition);
|
|
823
|
+
border-left: 3px solid transparent;
|
|
824
|
+
}
|
|
825
|
+
.scene-card:hover { background: var(--surface2); }
|
|
826
|
+
.scene-card.active { background: var(--accent-glow); border-left-color: var(--accent); }
|
|
827
|
+
.scene-card.modified { border-left-color: var(--warning); }
|
|
828
|
+
.scene-card.active.modified { border-left-color: var(--warning); }
|
|
829
|
+
.scene-card .scene-body { display: none; }
|
|
830
|
+
.scene-card.expanded .scene-body { display: block; }
|
|
831
|
+
.scene-card .scene-name .expand-icon {
|
|
832
|
+
margin-left: auto;
|
|
833
|
+
font-size: 10px;
|
|
834
|
+
color: var(--text-dim);
|
|
835
|
+
transition: transform var(--transition);
|
|
836
|
+
cursor: pointer;
|
|
837
|
+
padding: 4px 6px;
|
|
838
|
+
border-radius: 4px;
|
|
839
|
+
}
|
|
840
|
+
.scene-card .scene-name .expand-icon:hover {
|
|
841
|
+
color: var(--text);
|
|
842
|
+
background: var(--surface3);
|
|
843
|
+
}
|
|
844
|
+
.scene-card.expanded .scene-name .expand-icon { transform: rotate(90deg); }
|
|
845
|
+
.scene-card .scene-name {
|
|
846
|
+
font-family: var(--mono);
|
|
847
|
+
font-size: 13px;
|
|
848
|
+
font-weight: 600;
|
|
849
|
+
margin-bottom: 6px;
|
|
850
|
+
display: flex;
|
|
851
|
+
align-items: center;
|
|
852
|
+
gap: 8px;
|
|
853
|
+
}
|
|
854
|
+
.scene-card .scene-time {
|
|
855
|
+
font-family: var(--mono);
|
|
856
|
+
font-size: 11px;
|
|
857
|
+
color: var(--text-muted);
|
|
858
|
+
font-weight: 400;
|
|
859
|
+
}
|
|
860
|
+
.scene-card .scene-duration {
|
|
861
|
+
font-family: var(--mono);
|
|
862
|
+
font-size: 11px;
|
|
863
|
+
color: var(--text-muted);
|
|
864
|
+
background: var(--surface2);
|
|
865
|
+
padding: 2px 6px;
|
|
866
|
+
border-radius: 4px;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/* Editable fields */
|
|
870
|
+
.field-group { margin-top: 8px; }
|
|
871
|
+
.field-group label {
|
|
872
|
+
display: block;
|
|
873
|
+
font-size: 11px;
|
|
874
|
+
color: var(--text-muted);
|
|
875
|
+
margin-bottom: 3px;
|
|
876
|
+
text-transform: uppercase;
|
|
877
|
+
letter-spacing: 0.04em;
|
|
878
|
+
}
|
|
879
|
+
.field-group textarea, .field-group input, .field-group select {
|
|
880
|
+
width: 100%;
|
|
881
|
+
background: var(--surface2);
|
|
882
|
+
border: 1px solid var(--border);
|
|
883
|
+
border-radius: 6px;
|
|
884
|
+
color: var(--text);
|
|
885
|
+
padding: 6px 10px;
|
|
886
|
+
font-size: 13px;
|
|
887
|
+
font-family: inherit;
|
|
888
|
+
resize: vertical;
|
|
889
|
+
}
|
|
890
|
+
.field-group textarea { min-height: 50px; }
|
|
891
|
+
.field-group textarea:focus, .field-group input:focus, .field-group select:focus {
|
|
892
|
+
outline: none;
|
|
893
|
+
border-color: var(--accent);
|
|
894
|
+
box-shadow: 0 0 0 2px var(--accent-glow);
|
|
895
|
+
}
|
|
896
|
+
.hint-text {
|
|
897
|
+
font-size: 11px;
|
|
898
|
+
color: var(--text-muted);
|
|
899
|
+
font-style: italic;
|
|
900
|
+
margin-top: 2px;
|
|
901
|
+
}
|
|
902
|
+
.scene-scrub input[type="range"] {
|
|
903
|
+
-webkit-appearance: none;
|
|
904
|
+
width: 100%;
|
|
905
|
+
height: 4px;
|
|
906
|
+
background: var(--surface3);
|
|
907
|
+
border-radius: 2px;
|
|
908
|
+
border: 0;
|
|
909
|
+
padding: 0;
|
|
910
|
+
outline: none;
|
|
911
|
+
}
|
|
912
|
+
.scene-scrub input[type="range"]::-webkit-slider-thumb {
|
|
913
|
+
-webkit-appearance: none;
|
|
914
|
+
width: 12px;
|
|
915
|
+
height: 12px;
|
|
916
|
+
border-radius: 50%;
|
|
917
|
+
background: var(--accent);
|
|
918
|
+
cursor: pointer;
|
|
919
|
+
border: 2px solid var(--surface);
|
|
920
|
+
}
|
|
921
|
+
.scene-scrub input[type="range"]::-webkit-slider-thumb:hover {
|
|
922
|
+
background: var(--accent-hover);
|
|
923
|
+
transform: scale(1.2);
|
|
924
|
+
}
|
|
925
|
+
.scene-scrub-meta {
|
|
926
|
+
display: flex;
|
|
927
|
+
justify-content: space-between;
|
|
928
|
+
font-family: var(--mono);
|
|
929
|
+
font-size: 11px;
|
|
930
|
+
color: var(--text-muted);
|
|
931
|
+
margin-top: 4px;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/* Buttons */
|
|
935
|
+
.btn {
|
|
936
|
+
display: inline-flex;
|
|
937
|
+
align-items: center;
|
|
938
|
+
gap: 4px;
|
|
939
|
+
padding: 5px 12px;
|
|
940
|
+
font-size: 12px;
|
|
941
|
+
font-weight: 500;
|
|
942
|
+
border: 1px solid var(--border);
|
|
943
|
+
border-radius: var(--radius);
|
|
944
|
+
background: var(--surface2);
|
|
945
|
+
color: var(--text);
|
|
946
|
+
cursor: pointer;
|
|
947
|
+
transition: all var(--transition);
|
|
948
|
+
}
|
|
949
|
+
.btn:hover:not(:disabled) { border-color: var(--text-muted); transform: translateY(-1px); }
|
|
950
|
+
.btn:active:not(:disabled) { transform: translateY(0); }
|
|
951
|
+
.btn-accent {
|
|
952
|
+
background: var(--accent);
|
|
953
|
+
border-color: var(--accent);
|
|
954
|
+
color: white;
|
|
955
|
+
}
|
|
956
|
+
.btn-accent:hover { opacity: 0.9; }
|
|
957
|
+
.btn-save {
|
|
958
|
+
background: var(--success);
|
|
959
|
+
border-color: var(--success);
|
|
960
|
+
color: #000;
|
|
961
|
+
font-weight: 600;
|
|
962
|
+
}
|
|
963
|
+
.btn-save:hover:not(:disabled) { background: #16a34a; }
|
|
964
|
+
.btn-save.dirty {
|
|
965
|
+
background: var(--warning);
|
|
966
|
+
border-color: var(--warning);
|
|
967
|
+
color: #000;
|
|
968
|
+
animation: pulse-save 2s ease-in-out infinite;
|
|
969
|
+
}
|
|
970
|
+
@keyframes pulse-save {
|
|
971
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); }
|
|
972
|
+
50% { box-shadow: 0 0 8px 2px rgba(245, 158, 11, 0.3); }
|
|
973
|
+
}
|
|
974
|
+
.btn-save.saved {
|
|
975
|
+
background: transparent;
|
|
976
|
+
border-color: var(--success);
|
|
977
|
+
color: var(--success);
|
|
978
|
+
}
|
|
979
|
+
.btn-undo {
|
|
980
|
+
background: transparent;
|
|
981
|
+
border-color: var(--warning);
|
|
982
|
+
color: var(--warning);
|
|
983
|
+
font-size: 11px;
|
|
984
|
+
padding: 4px 10px;
|
|
985
|
+
}
|
|
986
|
+
.btn-undo:hover:not(:disabled) {
|
|
987
|
+
background: rgba(245, 158, 11, 0.1);
|
|
988
|
+
}
|
|
989
|
+
.btn-rerecord {
|
|
990
|
+
background: transparent;
|
|
991
|
+
border-color: var(--accent);
|
|
992
|
+
color: var(--accent);
|
|
993
|
+
font-weight: 500;
|
|
994
|
+
}
|
|
995
|
+
.btn-rerecord:hover:not(:disabled) {
|
|
996
|
+
background: var(--accent-glow);
|
|
997
|
+
}
|
|
998
|
+
.btn-rerecord:disabled {
|
|
999
|
+
opacity: 0.4;
|
|
1000
|
+
cursor: not-allowed;
|
|
1001
|
+
}
|
|
1002
|
+
.btn-play {
|
|
1003
|
+
width: 32px;
|
|
1004
|
+
height: 32px;
|
|
1005
|
+
border-radius: 50%;
|
|
1006
|
+
padding: 0;
|
|
1007
|
+
display: flex;
|
|
1008
|
+
align-items: center;
|
|
1009
|
+
justify-content: center;
|
|
1010
|
+
}
|
|
1011
|
+
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
1012
|
+
.btn-row { display: flex; gap: 6px; margin-top: 8px; }
|
|
1013
|
+
.btn-group {
|
|
1014
|
+
display: inline-flex;
|
|
1015
|
+
gap: 0;
|
|
1016
|
+
}
|
|
1017
|
+
.btn-group .btn {
|
|
1018
|
+
border-radius: 0;
|
|
1019
|
+
}
|
|
1020
|
+
.btn-group .btn:first-child { border-radius: var(--radius) 0 0 var(--radius); }
|
|
1021
|
+
.btn-group .btn:last-child { border-radius: 0 var(--radius) var(--radius) 0; }
|
|
1022
|
+
.btn-group .btn + .btn { border-left: 0; }
|
|
1023
|
+
|
|
1024
|
+
/* Status indicator */
|
|
1025
|
+
.status {
|
|
1026
|
+
padding: 8px 16px;
|
|
1027
|
+
font-family: var(--mono);
|
|
1028
|
+
font-size: 12px;
|
|
1029
|
+
color: var(--text-muted);
|
|
1030
|
+
border-top: 1px solid var(--border);
|
|
1031
|
+
margin-top: auto;
|
|
1032
|
+
}
|
|
1033
|
+
.status.saving { color: var(--warning); }
|
|
1034
|
+
.status.saved { color: var(--success); }
|
|
1035
|
+
.status.error { color: var(--error); }
|
|
1036
|
+
|
|
1037
|
+
/* Overlay type selector */
|
|
1038
|
+
.overlay-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
|
|
1039
|
+
.overlay-section .section-title {
|
|
1040
|
+
font-size: 11px;
|
|
1041
|
+
color: var(--text-muted);
|
|
1042
|
+
text-transform: uppercase;
|
|
1043
|
+
letter-spacing: 0.04em;
|
|
1044
|
+
margin-bottom: 6px;
|
|
1045
|
+
}
|
|
1046
|
+
</style>
|
|
1047
|
+
</head>
|
|
1048
|
+
<body>
|
|
1049
|
+
|
|
1050
|
+
<header>
|
|
1051
|
+
<h1>Argo Preview — <span class="demo-name" id="demo-name"></span></h1>
|
|
1052
|
+
<div class="actions">
|
|
1053
|
+
<a class="trace-link" id="trace-link" href="https://trace.playwright.dev" target="_blank">Open Trace Viewer</a>
|
|
1054
|
+
<button class="btn btn-save" id="btn-save" title="Save all changes">Save</button>
|
|
1055
|
+
<button class="btn btn-rerecord" id="btn-rerecord" title="Re-record with current manifest">Re-record</button>
|
|
1056
|
+
</div>
|
|
1057
|
+
</header>
|
|
1058
|
+
|
|
1059
|
+
<div class="viewer">
|
|
1060
|
+
<div class="video-container">
|
|
1061
|
+
<video id="video" src="/video" preload="auto" muted playsinline></video>
|
|
1062
|
+
<div class="overlay-layer" id="overlay-layer"></div>
|
|
1063
|
+
</div>
|
|
1064
|
+
|
|
1065
|
+
<div class="timeline">
|
|
1066
|
+
<div class="timeline-bar" id="timeline-bar">
|
|
1067
|
+
<div class="timeline-progress" id="timeline-progress"></div>
|
|
1068
|
+
<div class="timeline-playhead" id="timeline-playhead"></div>
|
|
1069
|
+
</div>
|
|
1070
|
+
<div class="timeline-time">
|
|
1071
|
+
<span id="time-current">0:00</span>
|
|
1072
|
+
<span id="time-total">0:00</span>
|
|
1073
|
+
</div>
|
|
1074
|
+
<div class="audio-controls">
|
|
1075
|
+
<button class="btn btn-play" id="btn-play" title="Play/Pause">
|
|
1076
|
+
<svg id="icon-play" width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><polygon points="3,1 13,8 3,15"/></svg>
|
|
1077
|
+
<svg id="icon-pause" width="14" height="14" viewBox="0 0 16 16" fill="currentColor" style="display:none"><rect x="2" y="1" width="4" height="14"/><rect x="10" y="1" width="4" height="14"/></svg>
|
|
1078
|
+
</button>
|
|
1079
|
+
<div class="toggle-group">
|
|
1080
|
+
<label class="toggle-switch" title="Audio">
|
|
1081
|
+
<input type="checkbox" id="cb-audio" checked>
|
|
1082
|
+
<span class="slider"></span>
|
|
1083
|
+
</label>
|
|
1084
|
+
<span class="toggle-label">Audio</span>
|
|
1085
|
+
</div>
|
|
1086
|
+
<div class="toggle-group">
|
|
1087
|
+
<label class="toggle-switch" title="Overlays">
|
|
1088
|
+
<input type="checkbox" id="cb-overlays" checked>
|
|
1089
|
+
<span class="slider"></span>
|
|
1090
|
+
</label>
|
|
1091
|
+
<span class="toggle-label">Overlays</span>
|
|
1092
|
+
</div>
|
|
1093
|
+
</div>
|
|
1094
|
+
</div>
|
|
1095
|
+
</div>
|
|
1096
|
+
|
|
1097
|
+
<div class="sidebar">
|
|
1098
|
+
<div class="sidebar-tabs">
|
|
1099
|
+
<button class="sidebar-tab active" data-tab="scenes">Scenes</button>
|
|
1100
|
+
<button class="sidebar-tab" data-tab="metadata">Metadata</button>
|
|
1101
|
+
</div>
|
|
1102
|
+
<div class="sidebar-panel" id="panel-scenes">
|
|
1103
|
+
<div id="scene-list"></div>
|
|
1104
|
+
</div>
|
|
1105
|
+
<div class="sidebar-panel" id="panel-metadata" style="display:none">
|
|
1106
|
+
<div id="metadata-content" style="padding:16px;font-family:var(--mono);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;"></div>
|
|
1107
|
+
</div>
|
|
1108
|
+
<div class="status" id="status">Ready</div>
|
|
1109
|
+
</div>
|
|
1110
|
+
|
|
1111
|
+
<script>
|
|
1112
|
+
// ─── Bootstrap ─────────────────────────────────────────────────────────────
|
|
1113
|
+
const DATA = __PREVIEW_DATA__;
|
|
1114
|
+
const video = document.getElementById('video');
|
|
1115
|
+
const overlayLayer = document.getElementById('overlay-layer');
|
|
1116
|
+
const timelineBar = document.getElementById('timeline-bar');
|
|
1117
|
+
const timelineProgress = document.getElementById('timeline-progress');
|
|
1118
|
+
const sceneList = document.getElementById('scene-list');
|
|
1119
|
+
const statusEl = document.getElementById('status');
|
|
1120
|
+
|
|
1121
|
+
document.getElementById('demo-name').textContent = DATA.demoName;
|
|
1122
|
+
|
|
1123
|
+
// Audio context for playing clips alongside video
|
|
1124
|
+
let audioCtx = null;
|
|
1125
|
+
let alignedAudioBuffer = null;
|
|
1126
|
+
let audioSource = null;
|
|
1127
|
+
let scenePlaybackEndMs = null;
|
|
1128
|
+
let latestSeekRequest = 0;
|
|
1129
|
+
const scrubState = new Map();
|
|
1130
|
+
|
|
1131
|
+
async function initAudio() {
|
|
1132
|
+
if (!audioCtx) audioCtx = new AudioContext();
|
|
1133
|
+
try {
|
|
1134
|
+
const resp = await fetch('/narration-aligned.wav');
|
|
1135
|
+
const buf = await resp.arrayBuffer();
|
|
1136
|
+
alignedAudioBuffer = await audioCtx.decodeAudioData(buf);
|
|
1137
|
+
} catch (e) {
|
|
1138
|
+
console.warn('Could not load aligned audio:', e);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// ─── Scene data ────────────────────────────────────────────────────────────
|
|
1143
|
+
// Sort scenes by timing
|
|
1144
|
+
const scenes = Object.entries(DATA.timing)
|
|
1145
|
+
.sort((a, b) => a[1] - b[1])
|
|
1146
|
+
.map(([name, startMs]) => ({
|
|
1147
|
+
name,
|
|
1148
|
+
startMs,
|
|
1149
|
+
vo: DATA.voiceover.find(v => v.scene === name),
|
|
1150
|
+
overlay: DATA.overlays.find(o => o.scene === name),
|
|
1151
|
+
rendered: DATA.renderedOverlays[name],
|
|
1152
|
+
report: DATA.sceneReport?.scenes?.find(s => s.scene === name),
|
|
1153
|
+
}));
|
|
1154
|
+
|
|
1155
|
+
let activeScene = null;
|
|
1156
|
+
|
|
1157
|
+
// ─── Timeline ──────────────────────────────────────────────────────────────
|
|
1158
|
+
video.addEventListener('loadedmetadata', () => {
|
|
1159
|
+
const totalMs = video.duration * 1000;
|
|
1160
|
+
document.getElementById('time-total').textContent = formatTime(totalMs);
|
|
1161
|
+
|
|
1162
|
+
// Render scene markers on timeline
|
|
1163
|
+
scenes.forEach((s, i) => {
|
|
1164
|
+
const pct = (s.startMs / totalMs) * 100;
|
|
1165
|
+
const nextStart = i + 1 < scenes.length ? scenes[i + 1].startMs : totalMs;
|
|
1166
|
+
const widthPct = ((nextStart - s.startMs) / totalMs) * 100;
|
|
1167
|
+
|
|
1168
|
+
const marker = document.createElement('div');
|
|
1169
|
+
marker.className = 'timeline-scene';
|
|
1170
|
+
marker.style.left = pct + '%';
|
|
1171
|
+
marker.style.width = Math.max(widthPct, 2) + '%';
|
|
1172
|
+
const hasOverlay = DATA.overlays.find(o => o.scene === s.name);
|
|
1173
|
+
// s.name is already escaped via esc() — safe for innerHTML
|
|
1174
|
+
marker.innerHTML = esc(s.name) + (hasOverlay ? '<span class="has-overlay"></span>' : '');
|
|
1175
|
+
marker.dataset.scene = s.name;
|
|
1176
|
+
marker.addEventListener('click', (e) => {
|
|
1177
|
+
e.stopPropagation();
|
|
1178
|
+
seekToScene(s);
|
|
1179
|
+
});
|
|
1180
|
+
timelineBar.appendChild(marker);
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
// Create overlay DOM elements
|
|
1184
|
+
renderOverlayElements();
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
video.addEventListener('timeupdate', () => {
|
|
1188
|
+
const totalMs = video.duration * 1000;
|
|
1189
|
+
const currentMs = video.currentTime * 1000;
|
|
1190
|
+
if (scenePlaybackEndMs !== null && currentMs >= scenePlaybackEndMs) {
|
|
1191
|
+
const stopAt = scenePlaybackEndMs;
|
|
1192
|
+
scenePlaybackEndMs = null;
|
|
1193
|
+
video.currentTime = stopAt / 1000;
|
|
1194
|
+
video.pause();
|
|
1195
|
+
stopAudio();
|
|
1196
|
+
}
|
|
1197
|
+
timelineProgress.style.width = ((currentMs / totalMs) * 100) + '%';
|
|
1198
|
+
document.getElementById('timeline-playhead').style.left = ((currentMs / totalMs) * 100) + '%';
|
|
1199
|
+
document.getElementById('time-current').textContent = formatTime(currentMs);
|
|
1200
|
+
updateSceneScrubUI(currentMs);
|
|
1201
|
+
|
|
1202
|
+
// Update active scene
|
|
1203
|
+
let current = null;
|
|
1204
|
+
for (let i = scenes.length - 1; i >= 0; i--) {
|
|
1205
|
+
if (currentMs >= scenes[i].startMs) {
|
|
1206
|
+
current = scenes[i];
|
|
1207
|
+
break;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
if (current !== activeScene) {
|
|
1211
|
+
activeScene = current;
|
|
1212
|
+
updateActiveSceneUI();
|
|
1213
|
+
updateOverlayVisibility(currentMs);
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// Click on timeline bar to seek
|
|
1218
|
+
timelineBar.addEventListener('click', (e) => {
|
|
1219
|
+
const rect = timelineBar.getBoundingClientRect();
|
|
1220
|
+
const pct = (e.clientX - rect.left) / rect.width;
|
|
1221
|
+
const seekTime = pct * video.duration;
|
|
1222
|
+
scenePlaybackEndMs = null;
|
|
1223
|
+
void seekAbsoluteMs(seekTime * 1000);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
// Play/pause icon toggling
|
|
1227
|
+
function showPlayIcon() {
|
|
1228
|
+
const p = document.getElementById('icon-play');
|
|
1229
|
+
const s = document.getElementById('icon-pause');
|
|
1230
|
+
if (p) p.style.display = '';
|
|
1231
|
+
if (s) s.style.display = 'none';
|
|
1232
|
+
}
|
|
1233
|
+
function showPauseIcon() {
|
|
1234
|
+
const p = document.getElementById('icon-play');
|
|
1235
|
+
const s = document.getElementById('icon-pause');
|
|
1236
|
+
if (p) p.style.display = 'none';
|
|
1237
|
+
if (s) s.style.display = '';
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Play/pause toggle (shared by button and video click)
|
|
1241
|
+
async function togglePlayPause() {
|
|
1242
|
+
if (video.paused) {
|
|
1243
|
+
await video.play();
|
|
1244
|
+
if (document.getElementById('cb-audio').checked) await playAudio();
|
|
1245
|
+
showPauseIcon();
|
|
1246
|
+
} else {
|
|
1247
|
+
video.pause();
|
|
1248
|
+
stopAudio();
|
|
1249
|
+
showPlayIcon();
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
function pausePreview() {
|
|
1253
|
+
if (!video.paused) {
|
|
1254
|
+
video.pause();
|
|
1255
|
+
stopAudio();
|
|
1256
|
+
showPlayIcon();
|
|
1257
|
+
}
|
|
1258
|
+
scenePlaybackEndMs = null;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
document.getElementById('btn-play').addEventListener('click', togglePlayPause);
|
|
1262
|
+
video.addEventListener('click', togglePlayPause);
|
|
1263
|
+
|
|
1264
|
+
video.addEventListener('pause', () => {
|
|
1265
|
+
if (!video.ended) {
|
|
1266
|
+
stopAudio();
|
|
1267
|
+
showPlayIcon();
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
video.addEventListener('ended', () => {
|
|
1272
|
+
stopAudio();
|
|
1273
|
+
showPlayIcon();
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
// Audio checkbox
|
|
1277
|
+
document.getElementById('cb-audio').addEventListener('change', async (e) => {
|
|
1278
|
+
if (e.target.checked && !video.paused) {
|
|
1279
|
+
await playAudio();
|
|
1280
|
+
} else {
|
|
1281
|
+
stopAudio();
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
async function playAudio() {
|
|
1286
|
+
if (!audioCtx || !alignedAudioBuffer) await initAudio();
|
|
1287
|
+
if (!audioCtx || !alignedAudioBuffer) return;
|
|
1288
|
+
if (audioCtx.state === 'suspended') {
|
|
1289
|
+
await audioCtx.resume();
|
|
1290
|
+
}
|
|
1291
|
+
stopAudio();
|
|
1292
|
+
audioSource = audioCtx.createBufferSource();
|
|
1293
|
+
audioSource.buffer = alignedAudioBuffer;
|
|
1294
|
+
audioSource.connect(audioCtx.destination);
|
|
1295
|
+
audioSource.start(0, video.currentTime);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function stopAudio() {
|
|
1299
|
+
if (audioSource) {
|
|
1300
|
+
try { audioSource.stop(); } catch {}
|
|
1301
|
+
audioSource = null;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function syncAudio() {
|
|
1306
|
+
if (!video.paused && document.getElementById('cb-audio').checked) {
|
|
1307
|
+
void playAudio();
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// ─── Overlay rendering ────────────────────────────────────────────────────
|
|
1312
|
+
function renderOverlayElements() {
|
|
1313
|
+
overlayLayer.innerHTML = '';
|
|
1314
|
+
for (const s of scenes) {
|
|
1315
|
+
if (!s.rendered) continue;
|
|
1316
|
+
const el = document.createElement('div');
|
|
1317
|
+
el.className = 'overlay-cue';
|
|
1318
|
+
el.dataset.scene = s.name;
|
|
1319
|
+
el.dataset.zone = s.rendered.zone;
|
|
1320
|
+
el.innerHTML = '<span class="preview-badge">PREVIEW</span>' + s.rendered.html;
|
|
1321
|
+
Object.assign(el.style, s.rendered.styles);
|
|
1322
|
+
overlayLayer.appendChild(el);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function updateOverlayVisibility(currentMs) {
|
|
1327
|
+
if (!document.getElementById('cb-overlays').checked) {
|
|
1328
|
+
overlayLayer.querySelectorAll('.overlay-cue').forEach(el => el.classList.remove('visible'));
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
for (const s of scenes) {
|
|
1333
|
+
const el = overlayLayer.querySelector('[data-scene="' + s.name + '"]');
|
|
1334
|
+
if (!el) continue;
|
|
1335
|
+
|
|
1336
|
+
// Show overlay only during this scene's own duration (not bleeding into next scene)
|
|
1337
|
+
const { startMs, endMs } = getSceneBounds(s);
|
|
1338
|
+
const isActive = currentMs >= startMs && currentMs < endMs;
|
|
1339
|
+
el.classList.toggle('visible', isActive);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
document.getElementById('cb-overlays').addEventListener('change', () => {
|
|
1344
|
+
updateOverlayVisibility(video.currentTime * 1000);
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
// ─── Scene list (sidebar) ──────────────────────────────────────────────────
|
|
1348
|
+
function renderSceneList() {
|
|
1349
|
+
sceneList.innerHTML = '';
|
|
1350
|
+
for (const s of scenes) {
|
|
1351
|
+
const card = document.createElement('div');
|
|
1352
|
+
card.className = 'scene-card';
|
|
1353
|
+
card.dataset.scene = s.name;
|
|
1354
|
+
|
|
1355
|
+
const durationMs = DATA.sceneDurations[s.name] ?? s.report?.durationMs ?? 0;
|
|
1356
|
+
|
|
1357
|
+
// scene-name and scene-duration use esc() — safe for innerHTML
|
|
1358
|
+
card.innerHTML = \`
|
|
1359
|
+
<div class="scene-name">
|
|
1360
|
+
\${esc(s.name)}
|
|
1361
|
+
<span class="scene-time">\${formatTime(s.startMs)}</span>
|
|
1362
|
+
\${durationMs ? '<span class="scene-duration">' + (durationMs / 1000).toFixed(1) + 's</span>' : ''}
|
|
1363
|
+
<span class="expand-icon">▶</span>
|
|
1364
|
+
</div>
|
|
1365
|
+
<div class="scene-body">
|
|
1366
|
+
<div class="field-group">
|
|
1367
|
+
<label>Voiceover text</label>
|
|
1368
|
+
<textarea data-field="text" data-scene="\${esc(s.name)}">\${esc(s.vo?.text ?? '')}</textarea>
|
|
1369
|
+
\${s.vo?._hint ? '<div class="hint-text">hint: ' + esc(s.vo._hint) + '</div>' : ''}
|
|
1370
|
+
</div>
|
|
1371
|
+
<div class="field-group" style="display:flex;gap:8px">
|
|
1372
|
+
<div style="flex:1">
|
|
1373
|
+
<label>Voice</label>
|
|
1374
|
+
<input data-field="voice" data-scene="\${esc(s.name)}" value="\${esc(s.vo?.voice ?? '')}" placeholder="default">
|
|
1375
|
+
</div>
|
|
1376
|
+
<div style="flex:1">
|
|
1377
|
+
<label>Speed</label>
|
|
1378
|
+
<input data-field="speed" data-scene="\${esc(s.name)}" type="number" step="0.1" min="0.5" max="2" value="\${s.vo?.speed ?? ''}\" placeholder="1.0">
|
|
1379
|
+
</div>
|
|
1380
|
+
</div>
|
|
1381
|
+
\${renderOverlayFields(s)}
|
|
1382
|
+
<div class="btn-row">
|
|
1383
|
+
<button class="btn btn-undo" data-scene="\${esc(s.name)}" onclick="undoScene('\${esc(s.name)}')" style="display:none" title="Revert to last saved state">Undo</button>
|
|
1384
|
+
<span class="btn-group"><button class="btn" onclick="previewScene('\${esc(s.name)}')" title="Play this scene">▶</button><button class="btn" onclick="pausePreview()" title="Pause">▮▮</button></span>
|
|
1385
|
+
<span class="btn-group"><button class="btn" onclick="nudgeScene('\${esc(s.name)}', -250)">-250ms</button><button class="btn" onclick="nudgeScene('\${esc(s.name)}', 250)">+250ms</button></span>
|
|
1386
|
+
<button class="btn btn-accent" onclick="regenClip('\${esc(s.name)}', this)">Regen TTS</button>
|
|
1387
|
+
</div>
|
|
1388
|
+
<div class="field-group scene-scrub">
|
|
1389
|
+
<label>Scene scrub</label>
|
|
1390
|
+
<input
|
|
1391
|
+
type="range"
|
|
1392
|
+
min="0"
|
|
1393
|
+
max="\${durationMs}"
|
|
1394
|
+
step="25"
|
|
1395
|
+
value="0"
|
|
1396
|
+
data-field="scene-scrub"
|
|
1397
|
+
data-scene="\${esc(s.name)}"
|
|
1398
|
+
\${durationMs ? '' : 'disabled'}
|
|
1399
|
+
>
|
|
1400
|
+
<div class="scene-scrub-meta">
|
|
1401
|
+
<span data-scene-scrub-current="\${esc(s.name)}">0.0s</span>
|
|
1402
|
+
<span data-scene-scrub-total="\${esc(s.name)}">\${(durationMs / 1000).toFixed(1)}s</span>
|
|
1403
|
+
</div>
|
|
1404
|
+
</div>
|
|
1405
|
+
</div>
|
|
1406
|
+
\`;
|
|
1407
|
+
|
|
1408
|
+
// Click on scene header row toggles expand/collapse + seeks
|
|
1409
|
+
card.addEventListener('click', (e) => {
|
|
1410
|
+
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT' ||
|
|
1411
|
+
e.target.tagName === 'BUTTON' || e.target.tagName === 'SELECT') return;
|
|
1412
|
+
if (e.target.closest('.scene-body')) return;
|
|
1413
|
+
const willCollapse = card.classList.contains('expanded');
|
|
1414
|
+
card.classList.toggle('expanded');
|
|
1415
|
+
if (willCollapse) {
|
|
1416
|
+
manuallyCollapsed.add(s.name);
|
|
1417
|
+
} else {
|
|
1418
|
+
manuallyCollapsed.delete(s.name);
|
|
1419
|
+
seekToScene(s);
|
|
1420
|
+
}
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
const scrub = card.querySelector('[data-field="scene-scrub"]');
|
|
1424
|
+
if (scrub) {
|
|
1425
|
+
scrub.addEventListener('input', (event) => {
|
|
1426
|
+
handleSceneScrubInput(s.name, event.target.value);
|
|
1427
|
+
});
|
|
1428
|
+
scrub.addEventListener('change', (event) => {
|
|
1429
|
+
handleSceneScrubCommit(s.name, event.target.value);
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
sceneList.appendChild(card);
|
|
1434
|
+
|
|
1435
|
+
// Wire overlay listeners AFTER appendChild so document.querySelector can find the card
|
|
1436
|
+
wireOverlayListeners(s.name);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function renderDynamicOverlayFields(sceneName, type, ov) {
|
|
1441
|
+
if (!type) return '';
|
|
1442
|
+
let fields = '';
|
|
1443
|
+
if (type === 'lower-third' || type === 'callout') {
|
|
1444
|
+
fields += \`
|
|
1445
|
+
<div class="field-group">
|
|
1446
|
+
<label>Text</label>
|
|
1447
|
+
<input data-field="overlay-text" data-scene="\${esc(sceneName)}" value="\${esc(ov?.text ?? '')}">
|
|
1448
|
+
</div>\`;
|
|
1449
|
+
} else if (type === 'headline-card') {
|
|
1450
|
+
fields += \`
|
|
1451
|
+
<div class="field-group">
|
|
1452
|
+
<label>Title</label>
|
|
1453
|
+
<input data-field="overlay-text" data-scene="\${esc(sceneName)}" value="\${esc(ov?.title ?? '')}">
|
|
1454
|
+
</div>
|
|
1455
|
+
<div class="field-group">
|
|
1456
|
+
<label>Body</label>
|
|
1457
|
+
<input data-field="overlay-body" data-scene="\${esc(sceneName)}" value="\${esc(ov?.body ?? '')}" placeholder="optional">
|
|
1458
|
+
</div>
|
|
1459
|
+
<div class="field-group">
|
|
1460
|
+
<label>Kicker</label>
|
|
1461
|
+
<input data-field="overlay-kicker" data-scene="\${esc(sceneName)}" value="\${esc(ov?.kicker ?? '')}" placeholder="optional">
|
|
1462
|
+
</div>\`;
|
|
1463
|
+
} else if (type === 'image-card') {
|
|
1464
|
+
fields += \`
|
|
1465
|
+
<div class="field-group">
|
|
1466
|
+
<label>Title</label>
|
|
1467
|
+
<input data-field="overlay-text" data-scene="\${esc(sceneName)}" value="\${esc(ov?.title ?? '')}" placeholder="optional">
|
|
1468
|
+
</div>
|
|
1469
|
+
<div class="field-group">
|
|
1470
|
+
<label>Body</label>
|
|
1471
|
+
<input data-field="overlay-body" data-scene="\${esc(sceneName)}" value="\${esc(ov?.body ?? '')}" placeholder="optional">
|
|
1472
|
+
</div>
|
|
1473
|
+
<div class="field-group">
|
|
1474
|
+
<label>Src</label>
|
|
1475
|
+
<input data-field="overlay-src" data-scene="\${esc(sceneName)}" value="\${esc(ov?.src ?? '')}" placeholder="assets/example.png">
|
|
1476
|
+
</div>\`;
|
|
1477
|
+
}
|
|
1478
|
+
return fields;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function renderOverlayFields(s) {
|
|
1482
|
+
const ov = s.overlay;
|
|
1483
|
+
const type = ov?.type ?? '';
|
|
1484
|
+
return \`
|
|
1485
|
+
<div class="overlay-section">
|
|
1486
|
+
<div class="section-title">Overlay</div>
|
|
1487
|
+
<div class="field-group" style="display:flex;gap:8px">
|
|
1488
|
+
<div style="flex:1">
|
|
1489
|
+
<label>Type</label>
|
|
1490
|
+
<select data-field="overlay-type" data-scene="\${esc(s.name)}">
|
|
1491
|
+
<option value="">none</option>
|
|
1492
|
+
<option value="lower-third" \${type === 'lower-third' ? 'selected' : ''}>lower-third</option>
|
|
1493
|
+
<option value="headline-card" \${type === 'headline-card' ? 'selected' : ''}>headline-card</option>
|
|
1494
|
+
<option value="callout" \${type === 'callout' ? 'selected' : ''}>callout</option>
|
|
1495
|
+
<option value="image-card" \${type === 'image-card' ? 'selected' : ''}>image-card</option>
|
|
1496
|
+
</select>
|
|
1497
|
+
</div>
|
|
1498
|
+
\${type ? \`<div style="flex:1">
|
|
1499
|
+
<label>Zone</label>
|
|
1500
|
+
<select data-field="overlay-placement" data-scene="\${esc(s.name)}">
|
|
1501
|
+
<option value="bottom-center" \${(ov?.placement ?? 'bottom-center') === 'bottom-center' ? 'selected' : ''}>bottom-center</option>
|
|
1502
|
+
<option value="top-left" \${ov?.placement === 'top-left' ? 'selected' : ''}>top-left</option>
|
|
1503
|
+
<option value="top-right" \${ov?.placement === 'top-right' ? 'selected' : ''}>top-right</option>
|
|
1504
|
+
<option value="bottom-left" \${ov?.placement === 'bottom-left' ? 'selected' : ''}>bottom-left</option>
|
|
1505
|
+
<option value="bottom-right" \${ov?.placement === 'bottom-right' ? 'selected' : ''}>bottom-right</option>
|
|
1506
|
+
<option value="center" \${ov?.placement === 'center' ? 'selected' : ''}>center</option>
|
|
1507
|
+
</select>
|
|
1508
|
+
</div>\` : ''}
|
|
1509
|
+
</div>
|
|
1510
|
+
\${type ? \`<div class="field-group">
|
|
1511
|
+
<label>Motion</label>
|
|
1512
|
+
<select data-field="overlay-motion" data-scene="\${esc(s.name)}">
|
|
1513
|
+
<option value="none" \${(ov?.motion ?? 'none') === 'none' ? 'selected' : ''}>none</option>
|
|
1514
|
+
<option value="fade-in" \${ov?.motion === 'fade-in' ? 'selected' : ''}>fade-in</option>
|
|
1515
|
+
<option value="slide-in" \${ov?.motion === 'slide-in' ? 'selected' : ''}>slide-in</option>
|
|
1516
|
+
</select>
|
|
1517
|
+
</div>\` : ''}
|
|
1518
|
+
<div class="overlay-fields-dynamic" data-scene="\${esc(s.name)}">
|
|
1519
|
+
\${renderDynamicOverlayFields(s.name, type, ov)}
|
|
1520
|
+
</div>
|
|
1521
|
+
</div>
|
|
1522
|
+
\`;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function updateOverlayFieldsForScene(sceneName) {
|
|
1526
|
+
const typeEl = document.querySelector('select[data-scene="' + sceneName + '"][data-field="overlay-type"]');
|
|
1527
|
+
const type = typeEl?.value ?? '';
|
|
1528
|
+
const s = scenes.find(sc => sc.name === sceneName);
|
|
1529
|
+
const ov = s?.overlay;
|
|
1530
|
+
const container = document.querySelector('.overlay-fields-dynamic[data-scene="' + sceneName + '"]');
|
|
1531
|
+
if (!container) return;
|
|
1532
|
+
// Re-render the dynamic fields — values come from esc() so safe for innerHTML
|
|
1533
|
+
container.innerHTML = renderDynamicOverlayFields(sceneName, type, ov);
|
|
1534
|
+
// Re-render the full overlay section to show/hide zone+motion
|
|
1535
|
+
const section = container.closest('.overlay-section');
|
|
1536
|
+
if (section) {
|
|
1537
|
+
// Temporarily build a fake scene object with updated overlay type for re-render
|
|
1538
|
+
const fakeOv = type ? { ...(ov ?? {}), type } : null;
|
|
1539
|
+
const fakeScene = { name: sceneName, overlay: fakeOv };
|
|
1540
|
+
section.outerHTML = renderOverlayFields(fakeScene);
|
|
1541
|
+
// Re-wire event listeners for the new overlay fields
|
|
1542
|
+
wireOverlayListeners(sceneName);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function wireOverlayListeners(sceneName) {
|
|
1547
|
+
const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
|
|
1548
|
+
if (!card) return;
|
|
1549
|
+
let debounceTimer;
|
|
1550
|
+
card.querySelectorAll('[data-field^="overlay"]').forEach(input => {
|
|
1551
|
+
const handler = () => {
|
|
1552
|
+
markDirty();
|
|
1553
|
+
clearTimeout(debounceTimer);
|
|
1554
|
+
debounceTimer = setTimeout(() => previewOverlays(), 300);
|
|
1555
|
+
};
|
|
1556
|
+
input.addEventListener('input', handler);
|
|
1557
|
+
input.addEventListener('change', handler);
|
|
1558
|
+
});
|
|
1559
|
+
// Re-wire the type change listener
|
|
1560
|
+
const typeSelect = card.querySelector('select[data-field="overlay-type"]');
|
|
1561
|
+
if (typeSelect) {
|
|
1562
|
+
typeSelect.addEventListener('change', () => updateOverlayFieldsForScene(sceneName));
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const manuallyCollapsed = new Set();
|
|
1567
|
+
|
|
1568
|
+
// ─── Actions ───────────────────────────────────────────────────────────────
|
|
1569
|
+
function seekToScene(s) {
|
|
1570
|
+
scenePlaybackEndMs = null;
|
|
1571
|
+
void seekAbsoluteMs(getSceneBounds(s).startMs);
|
|
1572
|
+
activeScene = s;
|
|
1573
|
+
updateActiveSceneUI();
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function updateActiveSceneUI() {
|
|
1577
|
+
document.querySelectorAll('.scene-card').forEach(c => c.classList.remove('active'));
|
|
1578
|
+
document.querySelectorAll('.timeline-scene').forEach(m => m.classList.remove('active'));
|
|
1579
|
+
if (activeScene) {
|
|
1580
|
+
const card = document.querySelector('.scene-card[data-scene="' + activeScene.name + '"]');
|
|
1581
|
+
if (card) {
|
|
1582
|
+
card.classList.add('active');
|
|
1583
|
+
// Auto-expand active scene (unless user manually collapsed it), collapse others
|
|
1584
|
+
document.querySelectorAll('.scene-card.expanded').forEach(c => {
|
|
1585
|
+
if (c !== card) c.classList.remove('expanded');
|
|
1586
|
+
});
|
|
1587
|
+
if (!manuallyCollapsed.has(activeScene.name)) {
|
|
1588
|
+
card.classList.add('expanded');
|
|
1589
|
+
}
|
|
1590
|
+
card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
1591
|
+
}
|
|
1592
|
+
const marker = document.querySelector('.timeline-scene[data-scene="' + activeScene.name + '"]');
|
|
1593
|
+
if (marker) marker.classList.add('active');
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function getSceneBounds(s) {
|
|
1598
|
+
const startMs = s.report?.startMs ?? s.startMs;
|
|
1599
|
+
const endMs = s.report?.endMs ?? (startMs + (DATA.sceneDurations[s.name] ?? 0));
|
|
1600
|
+
return {
|
|
1601
|
+
startMs,
|
|
1602
|
+
endMs,
|
|
1603
|
+
durationMs: Math.max(0, endMs - startMs),
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
async function seekAbsoluteMs(absoluteMs) {
|
|
1608
|
+
const targetMs = Math.max(0, absoluteMs);
|
|
1609
|
+
const targetSec = targetMs / 1000;
|
|
1610
|
+
const requestId = ++latestSeekRequest;
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
if (video.readyState < 1) {
|
|
1614
|
+
await new Promise(resolve => video.addEventListener('loadedmetadata', resolve, { once: true }));
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
if (Math.abs(video.currentTime - targetSec) > 0.01 || video.seeking) {
|
|
1618
|
+
await new Promise(resolve => {
|
|
1619
|
+
const onSeeked = () => resolve();
|
|
1620
|
+
video.addEventListener('seeked', onSeeked, { once: true });
|
|
1621
|
+
video.currentTime = targetSec;
|
|
1622
|
+
});
|
|
1623
|
+
} else {
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
if (requestId !== latestSeekRequest) {
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const totalMs = video.duration * 1000;
|
|
1631
|
+
if (totalMs > 0) {
|
|
1632
|
+
const pct = (targetMs / totalMs) * 100;
|
|
1633
|
+
timelineProgress.style.width = pct + '%';
|
|
1634
|
+
document.getElementById('timeline-playhead').style.left = pct + '%';
|
|
1635
|
+
}
|
|
1636
|
+
document.getElementById('time-current').textContent = formatTime(targetMs);
|
|
1637
|
+
updateOverlayVisibility(targetMs);
|
|
1638
|
+
updateSceneScrubUI(targetMs);
|
|
1639
|
+
syncAudio();
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function updateSceneScrubUI(currentMs = video.currentTime * 1000) {
|
|
1643
|
+
for (const s of scenes) {
|
|
1644
|
+
const { startMs, durationMs } = getSceneBounds(s);
|
|
1645
|
+
const localMs = Math.max(0, Math.min(durationMs, currentMs - startMs));
|
|
1646
|
+
const scrub = document.querySelector('[data-field="scene-scrub"][data-scene="' + s.name + '"]');
|
|
1647
|
+
if (scrub) {
|
|
1648
|
+
scrub.max = String(durationMs);
|
|
1649
|
+
scrub.value = String(localMs);
|
|
1650
|
+
scrub.disabled = durationMs <= 0;
|
|
1651
|
+
}
|
|
1652
|
+
const currentLabel = document.querySelector('[data-scene-scrub-current="' + s.name + '"]');
|
|
1653
|
+
const totalLabel = document.querySelector('[data-scene-scrub-total="' + s.name + '"]');
|
|
1654
|
+
if (currentLabel) currentLabel.textContent = formatSeconds(localMs);
|
|
1655
|
+
if (totalLabel) totalLabel.textContent = formatSeconds(durationMs);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
async function handleSceneScrubInput(sceneName, rawValue) {
|
|
1660
|
+
const s = scenes.find((scene) => scene.name === sceneName);
|
|
1661
|
+
if (!s) return;
|
|
1662
|
+
if (!scrubState.has(sceneName)) {
|
|
1663
|
+
scrubState.set(sceneName, { resumeAfter: !video.paused });
|
|
1664
|
+
video.pause();
|
|
1665
|
+
}
|
|
1666
|
+
const { startMs, durationMs } = getSceneBounds(s);
|
|
1667
|
+
const offsetMs = Math.max(0, Math.min(durationMs, Number(rawValue) || 0));
|
|
1668
|
+
scenePlaybackEndMs = null;
|
|
1669
|
+
activeScene = s;
|
|
1670
|
+
updateActiveSceneUI();
|
|
1671
|
+
await seekAbsoluteMs(startMs + offsetMs);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
async function handleSceneScrubCommit(sceneName, rawValue) {
|
|
1675
|
+
await handleSceneScrubInput(sceneName, rawValue);
|
|
1676
|
+
const state = scrubState.get(sceneName);
|
|
1677
|
+
scrubState.delete(sceneName);
|
|
1678
|
+
if (state?.resumeAfter) {
|
|
1679
|
+
void video.play().then(async () => {
|
|
1680
|
+
if (document.getElementById('cb-audio').checked) {
|
|
1681
|
+
await playAudio();
|
|
1682
|
+
}
|
|
1683
|
+
showPauseIcon();
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function nudgeScene(sceneName, deltaMs) {
|
|
1689
|
+
const s = scenes.find(s => s.name === sceneName);
|
|
1690
|
+
if (!s) return;
|
|
1691
|
+
const scrub = document.querySelector('[data-field="scene-scrub"][data-scene="' + sceneName + '"]');
|
|
1692
|
+
const currentMs = scrub ? Number(scrub.value) || 0 : 0;
|
|
1693
|
+
void handleSceneScrubCommit(sceneName, currentMs + deltaMs);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
async function previewScene(sceneName) {
|
|
1697
|
+
await initAudio();
|
|
1698
|
+
const s = scenes.find(s => s.name === sceneName);
|
|
1699
|
+
if (!s) return;
|
|
1700
|
+
const { startMs, endMs, durationMs } = getSceneBounds(s);
|
|
1701
|
+
if (!durationMs) return;
|
|
1702
|
+
// Pause first to prevent timeupdate race, then seek, then play
|
|
1703
|
+
video.pause();
|
|
1704
|
+
stopAudio();
|
|
1705
|
+
scenePlaybackEndMs = null;
|
|
1706
|
+
await seekAbsoluteMs(startMs);
|
|
1707
|
+
// Verify seek landed — some browsers reset on play()
|
|
1708
|
+
if (Math.abs(video.currentTime - startMs / 1000) > 0.1) {
|
|
1709
|
+
video.currentTime = startMs / 1000;
|
|
1710
|
+
await new Promise(r => video.addEventListener('seeked', r, { once: true }));
|
|
1711
|
+
}
|
|
1712
|
+
activeScene = s;
|
|
1713
|
+
updateActiveSceneUI();
|
|
1714
|
+
scenePlaybackEndMs = endMs;
|
|
1715
|
+
await video.play();
|
|
1716
|
+
if (document.getElementById('cb-audio').checked) await playAudio();
|
|
1717
|
+
showPauseIcon();
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
async function regenClip(sceneName, btn) {
|
|
1721
|
+
btn.disabled = true;
|
|
1722
|
+
btn.textContent = 'Generating...';
|
|
1723
|
+
setStatus('Regenerating TTS for ' + sceneName + '...', 'saving');
|
|
1724
|
+
|
|
1725
|
+
try {
|
|
1726
|
+
// Save current voiceover state first
|
|
1727
|
+
await saveVoiceover();
|
|
1728
|
+
|
|
1729
|
+
const resp = await fetch('/api/regen-clip', {
|
|
1730
|
+
method: 'POST',
|
|
1731
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1732
|
+
body: JSON.stringify({ scene: sceneName }),
|
|
1733
|
+
});
|
|
1734
|
+
const result = await resp.json();
|
|
1735
|
+
if (!resp.ok) throw new Error(result.error);
|
|
1736
|
+
|
|
1737
|
+
// Update local duration data
|
|
1738
|
+
if (result.sceneDurations) DATA.sceneDurations = result.sceneDurations;
|
|
1739
|
+
if (result.sceneReport) DATA.sceneReport = result.sceneReport;
|
|
1740
|
+
|
|
1741
|
+
// Reload aligned audio
|
|
1742
|
+
await initAudio();
|
|
1743
|
+
// Update scene objects
|
|
1744
|
+
for (const s of scenes) {
|
|
1745
|
+
s.vo = DATA.voiceover.find(v => v.scene === s.name);
|
|
1746
|
+
s.overlay = DATA.overlays.find(o => o.scene === s.name);
|
|
1747
|
+
s.rendered = DATA.renderedOverlays[s.name];
|
|
1748
|
+
s.report = DATA.sceneReport?.scenes?.find(r => r.scene === s.name);
|
|
1749
|
+
}
|
|
1750
|
+
updateSceneDuration(sceneName);
|
|
1751
|
+
updateSceneScrubUI(video.currentTime * 1000);
|
|
1752
|
+
|
|
1753
|
+
setStatus('TTS regenerated for ' + sceneName, 'saved');
|
|
1754
|
+
} catch (err) {
|
|
1755
|
+
setStatus('Regen failed: ' + err.message, 'error');
|
|
1756
|
+
} finally {
|
|
1757
|
+
btn.disabled = false;
|
|
1758
|
+
btn.textContent = 'Regen TTS';
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
function collectVoiceover() {
|
|
1763
|
+
return scenes.map(s => {
|
|
1764
|
+
const textEl = document.querySelector('textarea[data-scene="' + s.name + '"][data-field="text"]');
|
|
1765
|
+
const voiceEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="voice"]');
|
|
1766
|
+
const speedEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="speed"]');
|
|
1767
|
+
const entry = { ...(s.vo ?? { scene: s.name }), scene: s.name, text: textEl?.value ?? '' };
|
|
1768
|
+
if (voiceEl?.value) entry.voice = voiceEl.value;
|
|
1769
|
+
else delete entry.voice;
|
|
1770
|
+
|
|
1771
|
+
const speed = speedEl?.value ? parseFloat(speedEl.value) : undefined;
|
|
1772
|
+
if (Number.isFinite(speed)) entry.speed = speed;
|
|
1773
|
+
else delete entry.speed;
|
|
1774
|
+
|
|
1775
|
+
return entry;
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function collectOverlays() {
|
|
1780
|
+
return scenes
|
|
1781
|
+
.map(s => {
|
|
1782
|
+
const typeEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-type"]');
|
|
1783
|
+
const placeEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-placement"]');
|
|
1784
|
+
const motionEl = document.querySelector('select[data-scene="' + s.name + '"][data-field="overlay-motion"]');
|
|
1785
|
+
const textEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-text"]');
|
|
1786
|
+
const bodyEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-body"]');
|
|
1787
|
+
const kickerEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-kicker"]');
|
|
1788
|
+
const srcEl = document.querySelector('input[data-scene="' + s.name + '"][data-field="overlay-src"]');
|
|
1789
|
+
const type = typeEl?.value;
|
|
1790
|
+
if (!type) return null;
|
|
1791
|
+
const entry = {
|
|
1792
|
+
...(s.overlay ?? {}),
|
|
1793
|
+
scene: s.name,
|
|
1794
|
+
type,
|
|
1795
|
+
placement: placeEl?.value ?? 'bottom-center',
|
|
1796
|
+
};
|
|
1797
|
+
if (motionEl?.value && motionEl.value !== 'none') entry.motion = motionEl.value;
|
|
1798
|
+
else delete entry.motion;
|
|
1799
|
+
|
|
1800
|
+
delete entry.text;
|
|
1801
|
+
delete entry.title;
|
|
1802
|
+
delete entry.body;
|
|
1803
|
+
delete entry.kicker;
|
|
1804
|
+
delete entry.src;
|
|
1805
|
+
|
|
1806
|
+
if (type === 'lower-third' || type === 'callout') {
|
|
1807
|
+
entry.text = textEl?.value ?? '';
|
|
1808
|
+
} else {
|
|
1809
|
+
entry.title = textEl?.value ?? '';
|
|
1810
|
+
if (bodyEl?.value) entry.body = bodyEl.value;
|
|
1811
|
+
if (type === 'headline-card' && kickerEl?.value) entry.kicker = kickerEl.value;
|
|
1812
|
+
if (type === 'image-card' && srcEl?.value) entry.src = srcEl.value;
|
|
1813
|
+
}
|
|
1814
|
+
return entry;
|
|
1815
|
+
})
|
|
1816
|
+
.filter(Boolean);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
async function saveVoiceover() {
|
|
1820
|
+
const vo = collectVoiceover();
|
|
1821
|
+
await fetch('/api/voiceover', {
|
|
1822
|
+
method: 'POST',
|
|
1823
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1824
|
+
body: JSON.stringify(vo),
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// Render-only preview (no disk write) — called on every overlay field edit
|
|
1829
|
+
async function previewOverlays() {
|
|
1830
|
+
const ov = collectOverlays();
|
|
1831
|
+
const resp = await fetch('/api/render-overlays', {
|
|
1832
|
+
method: 'POST',
|
|
1833
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1834
|
+
body: JSON.stringify(ov),
|
|
1835
|
+
});
|
|
1836
|
+
const result = await resp.json();
|
|
1837
|
+
if (result.renderedOverlays) {
|
|
1838
|
+
DATA.renderedOverlays = result.renderedOverlays;
|
|
1839
|
+
DATA.overlays = ov;
|
|
1840
|
+
for (const s of scenes) {
|
|
1841
|
+
s.overlay = DATA.overlays.find(o => o.scene === s.name);
|
|
1842
|
+
s.rendered = DATA.renderedOverlays[s.name];
|
|
1843
|
+
}
|
|
1844
|
+
renderOverlayElements();
|
|
1845
|
+
updateOverlayVisibility(video.currentTime * 1000);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// Persist to disk — called only by Save button
|
|
1850
|
+
async function saveOverlays() {
|
|
1851
|
+
const ov = collectOverlays();
|
|
1852
|
+
const resp = await fetch('/api/overlays', {
|
|
1853
|
+
method: 'POST',
|
|
1854
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1855
|
+
body: JSON.stringify(ov),
|
|
1856
|
+
});
|
|
1857
|
+
const result = await resp.json();
|
|
1858
|
+
if (result.renderedOverlays) {
|
|
1859
|
+
DATA.renderedOverlays = result.renderedOverlays;
|
|
1860
|
+
DATA.overlays = ov;
|
|
1861
|
+
for (const s of scenes) {
|
|
1862
|
+
s.overlay = DATA.overlays.find(o => o.scene === s.name);
|
|
1863
|
+
s.rendered = DATA.renderedOverlays[s.name];
|
|
1864
|
+
}
|
|
1865
|
+
renderOverlayElements();
|
|
1866
|
+
updateOverlayVisibility(video.currentTime * 1000);
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// ─── Scene snapshots (for per-scene undo) ──────────────────────────────────
|
|
1871
|
+
const sceneSnapshots = new Map();
|
|
1872
|
+
|
|
1873
|
+
function snapshotAllScenes() {
|
|
1874
|
+
for (const s of scenes) {
|
|
1875
|
+
sceneSnapshots.set(s.name, {
|
|
1876
|
+
text: s.vo?.text ?? '',
|
|
1877
|
+
voice: s.vo?.voice ?? '',
|
|
1878
|
+
speed: s.vo?.speed ?? '',
|
|
1879
|
+
overlay: s.overlay ? JSON.parse(JSON.stringify(s.overlay)) : null,
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function getSceneSnapshot(sceneName) {
|
|
1885
|
+
return sceneSnapshots.get(sceneName);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function isSceneModified(sceneName) {
|
|
1889
|
+
const snap = getSceneSnapshot(sceneName);
|
|
1890
|
+
if (!snap) return false;
|
|
1891
|
+
const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
|
|
1892
|
+
if (!card) return false;
|
|
1893
|
+
const text = card.querySelector('[data-field="text"]')?.value ?? '';
|
|
1894
|
+
const voice = card.querySelector('[data-field="voice"]')?.value ?? '';
|
|
1895
|
+
const speed = card.querySelector('[data-field="speed"]')?.value ?? '';
|
|
1896
|
+
if (text !== snap.text || voice !== snap.voice || String(speed) !== String(snap.speed)) return true;
|
|
1897
|
+
// Check overlay fields
|
|
1898
|
+
const type = card.querySelector('[data-field="overlay-type"]')?.value ?? '';
|
|
1899
|
+
const snapType = snap.overlay?.type ?? '';
|
|
1900
|
+
if (type !== snapType) return true;
|
|
1901
|
+
if (type) {
|
|
1902
|
+
const placement = card.querySelector('[data-field="overlay-placement"]')?.value ?? '';
|
|
1903
|
+
const motion = card.querySelector('[data-field="overlay-motion"]')?.value ?? '';
|
|
1904
|
+
const overlayText = card.querySelector('[data-field="overlay-text"]')?.value ?? '';
|
|
1905
|
+
const body = card.querySelector('[data-field="overlay-body"]')?.value ?? '';
|
|
1906
|
+
const kicker = card.querySelector('[data-field="overlay-kicker"]')?.value ?? '';
|
|
1907
|
+
const src = card.querySelector('[data-field="overlay-src"]')?.value ?? '';
|
|
1908
|
+
const so = snap.overlay || {};
|
|
1909
|
+
if (placement !== (so.placement ?? 'bottom-center')) return true;
|
|
1910
|
+
if (motion !== (so.motion ?? 'none')) return true;
|
|
1911
|
+
const snapText = so.type === 'lower-third' || so.type === 'callout' ? (so.text ?? '') : (so.title ?? '');
|
|
1912
|
+
if (overlayText !== snapText) return true;
|
|
1913
|
+
if (body !== (so.body ?? '')) return true;
|
|
1914
|
+
if (kicker !== (so.kicker ?? '')) return true;
|
|
1915
|
+
if (src !== (so.src ?? '')) return true;
|
|
1916
|
+
}
|
|
1917
|
+
return false;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function updateUndoButton(sceneName) {
|
|
1921
|
+
const btn = document.querySelector('.btn-undo[data-scene="' + sceneName + '"]');
|
|
1922
|
+
const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
|
|
1923
|
+
const modified = isSceneModified(sceneName);
|
|
1924
|
+
if (btn) btn.style.display = modified ? '' : 'none';
|
|
1925
|
+
if (card) card.classList.toggle('modified', modified);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function updateAllUndoButtons() {
|
|
1929
|
+
for (const s of scenes) updateUndoButton(s.name);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function undoScene(sceneName) {
|
|
1933
|
+
const snap = getSceneSnapshot(sceneName);
|
|
1934
|
+
if (!snap) return;
|
|
1935
|
+
const card = document.querySelector('.scene-card[data-scene="' + sceneName + '"]');
|
|
1936
|
+
if (!card) return;
|
|
1937
|
+
// Restore voiceover fields
|
|
1938
|
+
const textEl = card.querySelector('[data-field="text"]');
|
|
1939
|
+
if (textEl) textEl.value = snap.text;
|
|
1940
|
+
const voiceEl = card.querySelector('[data-field="voice"]');
|
|
1941
|
+
if (voiceEl) voiceEl.value = snap.voice;
|
|
1942
|
+
const speedEl = card.querySelector('[data-field="speed"]');
|
|
1943
|
+
if (speedEl) speedEl.value = snap.speed;
|
|
1944
|
+
// Restore overlay type (triggers field re-render)
|
|
1945
|
+
const typeEl = card.querySelector('[data-field="overlay-type"]');
|
|
1946
|
+
if (typeEl) {
|
|
1947
|
+
typeEl.value = snap.overlay?.type ?? '';
|
|
1948
|
+
updateOverlayFieldsForScene(sceneName);
|
|
1949
|
+
}
|
|
1950
|
+
// Restore overlay field values after re-render
|
|
1951
|
+
setTimeout(() => {
|
|
1952
|
+
const so = snap.overlay || {};
|
|
1953
|
+
const textField = card.querySelector('[data-field="overlay-text"]');
|
|
1954
|
+
if (textField) {
|
|
1955
|
+
textField.value = so.type === 'lower-third' || so.type === 'callout' ? (so.text ?? '') : (so.title ?? '');
|
|
1956
|
+
}
|
|
1957
|
+
const bodyField = card.querySelector('[data-field="overlay-body"]');
|
|
1958
|
+
if (bodyField) bodyField.value = so.body ?? '';
|
|
1959
|
+
const kickerField = card.querySelector('[data-field="overlay-kicker"]');
|
|
1960
|
+
if (kickerField) kickerField.value = so.kicker ?? '';
|
|
1961
|
+
const srcField = card.querySelector('[data-field="overlay-src"]');
|
|
1962
|
+
if (srcField) srcField.value = so.src ?? '';
|
|
1963
|
+
const placementField = card.querySelector('[data-field="overlay-placement"]');
|
|
1964
|
+
if (placementField) placementField.value = so.placement ?? 'bottom-center';
|
|
1965
|
+
const motionField = card.querySelector('[data-field="overlay-motion"]');
|
|
1966
|
+
if (motionField) motionField.value = so.motion ?? 'none';
|
|
1967
|
+
// Re-render overlay preview
|
|
1968
|
+
previewOverlays();
|
|
1969
|
+
updateUndoButton(sceneName);
|
|
1970
|
+
// Check if all scenes are back to saved state
|
|
1971
|
+
const anyModified = scenes.some(s => isSceneModified(s.name));
|
|
1972
|
+
if (!anyModified) clearDirty();
|
|
1973
|
+
}, 0);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// ─── Dirty state ───────────────────────────────────────────────────────────
|
|
1977
|
+
let isDirty = false;
|
|
1978
|
+
|
|
1979
|
+
function markDirty() {
|
|
1980
|
+
isDirty = true;
|
|
1981
|
+
const saveBtn = document.getElementById('btn-save');
|
|
1982
|
+
saveBtn.classList.add('dirty');
|
|
1983
|
+
saveBtn.textContent = '\\u25cf Save';
|
|
1984
|
+
updateAllUndoButtons();
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
function clearDirty() {
|
|
1988
|
+
isDirty = false;
|
|
1989
|
+
const saveBtn = document.getElementById('btn-save');
|
|
1990
|
+
saveBtn.classList.remove('dirty');
|
|
1991
|
+
saveBtn.textContent = 'Save';
|
|
1992
|
+
snapshotAllScenes();
|
|
1993
|
+
updateAllUndoButtons();
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// Save button
|
|
1997
|
+
document.getElementById('btn-save').addEventListener('click', async () => {
|
|
1998
|
+
const saveBtn = document.getElementById('btn-save');
|
|
1999
|
+
setStatus('Saving...', 'saving');
|
|
2000
|
+
try {
|
|
2001
|
+
await saveVoiceover();
|
|
2002
|
+
await saveOverlays();
|
|
2003
|
+
clearDirty();
|
|
2004
|
+
setStatus('All changes saved', 'saved');
|
|
2005
|
+
saveBtn.textContent = '\\u2713 Saved';
|
|
2006
|
+
saveBtn.classList.add('saved');
|
|
2007
|
+
setTimeout(() => {
|
|
2008
|
+
if (!isDirty) {
|
|
2009
|
+
saveBtn.textContent = 'Save';
|
|
2010
|
+
saveBtn.classList.remove('saved');
|
|
2011
|
+
}
|
|
2012
|
+
}, 2000);
|
|
2013
|
+
} catch (err) {
|
|
2014
|
+
setStatus('Save failed: ' + err.message, 'error');
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
// Re-record button
|
|
2019
|
+
document.getElementById('btn-rerecord').addEventListener('click', async () => {
|
|
2020
|
+
if (isDirty && !confirm('You have unsaved changes. Save before re-recording?')) return;
|
|
2021
|
+
if (isDirty) {
|
|
2022
|
+
await saveVoiceover();
|
|
2023
|
+
await saveOverlays();
|
|
2024
|
+
clearDirty();
|
|
2025
|
+
}
|
|
2026
|
+
const btn = document.getElementById('btn-rerecord');
|
|
2027
|
+
btn.disabled = true;
|
|
2028
|
+
btn.textContent = 'Recording...';
|
|
2029
|
+
setStatus('Re-recording pipeline...', 'saving');
|
|
2030
|
+
try {
|
|
2031
|
+
const resp = await fetch('/api/rerecord', { method: 'POST' });
|
|
2032
|
+
const result = await resp.json();
|
|
2033
|
+
if (!result.ok) throw new Error(result.error);
|
|
2034
|
+
setStatus('Re-record complete! Reloading...', 'saved');
|
|
2035
|
+
setTimeout(() => location.reload(), 1500);
|
|
2036
|
+
} catch (err) {
|
|
2037
|
+
setStatus('Re-record failed: ' + err.message, 'error');
|
|
2038
|
+
btn.disabled = false;
|
|
2039
|
+
btn.textContent = 'Re-record';
|
|
2040
|
+
}
|
|
2041
|
+
});
|
|
2042
|
+
|
|
2043
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
2044
|
+
function formatTime(ms) {
|
|
2045
|
+
const s = Math.floor(ms / 1000);
|
|
2046
|
+
const m = Math.floor(s / 60);
|
|
2047
|
+
return m + ':' + String(s % 60).padStart(2, '0');
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
function formatSeconds(ms) {
|
|
2051
|
+
return (Math.max(0, ms) / 1000).toFixed(1) + 's';
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
function esc(str) {
|
|
2055
|
+
if (!str) return '';
|
|
2056
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
function setStatus(msg, cls) {
|
|
2060
|
+
statusEl.textContent = msg;
|
|
2061
|
+
statusEl.className = 'status ' + (cls || '');
|
|
2062
|
+
if (cls === 'saved') setTimeout(() => { statusEl.textContent = 'Ready'; statusEl.className = 'status'; }, 3000);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function updateSceneDuration(sceneName) {
|
|
2066
|
+
const badge = document.querySelector('.scene-card[data-scene="' + sceneName + '"] .scene-duration');
|
|
2067
|
+
const durationMs = DATA.sceneDurations[sceneName];
|
|
2068
|
+
if (!badge || !durationMs) return;
|
|
2069
|
+
badge.textContent = (durationMs / 1000).toFixed(1) + 's';
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// ─── Sidebar tabs ──────────────────────────────────────────────────────────
|
|
2073
|
+
document.querySelectorAll('.sidebar-tab').forEach(tab => {
|
|
2074
|
+
tab.addEventListener('click', () => {
|
|
2075
|
+
document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
|
|
2076
|
+
tab.classList.add('active');
|
|
2077
|
+
document.querySelectorAll('.sidebar-panel').forEach(p => p.style.display = 'none');
|
|
2078
|
+
document.getElementById('panel-' + tab.dataset.tab).style.display = '';
|
|
2079
|
+
});
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
// Render metadata if available
|
|
2083
|
+
if (DATA.pipelineMeta) {
|
|
2084
|
+
const meta = DATA.pipelineMeta;
|
|
2085
|
+
const lines = [];
|
|
2086
|
+
lines.push('Created: ' + (meta.createdAt || 'unknown'));
|
|
2087
|
+
lines.push('');
|
|
2088
|
+
if (meta.tts) {
|
|
2089
|
+
lines.push('TTS Engine');
|
|
2090
|
+
const tts = meta.tts;
|
|
2091
|
+
for (const [k, v] of Object.entries(tts)) {
|
|
2092
|
+
lines.push(' ' + k + ': ' + v);
|
|
2093
|
+
}
|
|
2094
|
+
lines.push('');
|
|
2095
|
+
}
|
|
2096
|
+
if (meta.video) {
|
|
2097
|
+
lines.push('Video');
|
|
2098
|
+
const vid = meta.video;
|
|
2099
|
+
lines.push(' resolution: ' + vid.width + 'x' + vid.height);
|
|
2100
|
+
lines.push(' fps: ' + vid.fps);
|
|
2101
|
+
lines.push(' browser: ' + vid.browser);
|
|
2102
|
+
if (vid.deviceScaleFactor > 1) lines.push(' scale: ' + vid.deviceScaleFactor + 'x');
|
|
2103
|
+
lines.push('');
|
|
2104
|
+
}
|
|
2105
|
+
if (meta.export) {
|
|
2106
|
+
lines.push('Export');
|
|
2107
|
+
lines.push(' preset: ' + meta.export.preset);
|
|
2108
|
+
lines.push(' crf: ' + meta.export.crf);
|
|
2109
|
+
lines.push('');
|
|
2110
|
+
}
|
|
2111
|
+
if (meta.scenes) {
|
|
2112
|
+
lines.push('Scenes');
|
|
2113
|
+
for (const s of meta.scenes) {
|
|
2114
|
+
const dur = s.durationMs ? ' (' + (s.durationMs / 1000).toFixed(1) + 's)' : '';
|
|
2115
|
+
lines.push(' ' + s.scene + ': voice=' + (s.voice || 'default') + ' speed=' + (s.speed || 1) + dur);
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
document.getElementById('metadata-content').textContent = lines.join('\\n');
|
|
2119
|
+
} else {
|
|
2120
|
+
document.getElementById('metadata-content').textContent = 'No pipeline metadata found.\\n\\nRun argo pipeline to generate metadata.';
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// ─── Init ──────────────────────────────────────────────────────────────────
|
|
2124
|
+
renderSceneList();
|
|
2125
|
+
snapshotAllScenes();
|
|
2126
|
+
initAudio();
|
|
2127
|
+
updateSceneScrubUI(0);
|
|
2128
|
+
|
|
2129
|
+
// Mark dirty on any voiceover field edit (text, voice, speed)
|
|
2130
|
+
sceneList.addEventListener('input', (e) => {
|
|
2131
|
+
const field = e.target?.dataset?.field;
|
|
2132
|
+
if (field === 'text' || field === 'voice' || field === 'speed') markDirty();
|
|
2133
|
+
});
|
|
2134
|
+
</script>
|
|
2135
|
+
</body>
|
|
2136
|
+
</html>`;
|
|
2137
|
+
//# sourceMappingURL=preview.js.map
|