@lalalic/markcut 1.1.1 → 2.0.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/AGENTS.md +39 -15
- package/README.md +7 -3
- package/SKILL.md +26 -172
- package/docs/edit-mode.md +1 -1
- package/docs/label-mode.md +6 -4
- package/docs/markdown-descriptive.md +46 -1
- package/docs/system-prompt-edit.md +16 -0
- package/package.json +1 -1
- package/src/config.mjs +8 -3
- package/src/descriptive/compiler.test.ts +42 -42
- package/src/descriptive/compiler.ts +41 -52
- package/src/descriptive/markdown.ts +8 -4
- package/src/descriptive/resolve.test.ts +14 -7
- package/src/descriptive/resolve.ts +148 -20
- package/src/entry.tsx +8 -2
- package/src/player/browser.tsx +178 -54
- package/src/player/bundle/player.js +1168 -566
- package/src/player/components/EditControls.tsx +92 -0
- package/src/player/components/HeaderBar.tsx +60 -0
- package/src/player/components/LabelControls.tsx +367 -0
- package/src/player/components/SceneThumbnails.tsx +87 -0
- package/src/player/components/VariantBar.tsx +39 -0
- package/src/player/components/index.ts +5 -0
- package/src/player/pipeline.mjs +130 -66
- package/src/player/pipeline.ts +3 -1
- package/src/player/server-shared.mjs +5 -7
- package/src/player/server.mjs +194 -187
- package/src/render/cli.mjs +66 -13
- package/src/schema/index.ts +20 -23
- package/src/types/Audio.tsx +25 -33
- package/src/types/Component.tsx +18 -24
- package/src/types/Effect.tsx +31 -39
- package/src/types/Image.tsx +23 -30
- package/src/types/Include.tsx +70 -76
- package/src/types/Map.tsx +48 -44
- package/src/types/Rhythm.tsx +19 -27
- package/src/types/Video.tsx +40 -47
- package/src/utils/component-import-map.ts +93 -0
- package/src/utils/index.ts +23 -10
- package/src/vision/cli.mjs +6 -6
- package/templates/courseware/TEMPLATE.md +317 -0
- package/templates/courseware/agents/reviewer.md +84 -0
- package/templates/courseware/prompts/fix.md +30 -0
- package/templates/courseware/prompts/outline.md +53 -0
- package/templates/courseware/prompts/scene.md +64 -0
- package/tests/fixtures/audio.json +4 -2
- package/tests/fixtures/basic.json +2 -1
- package/tests/fixtures/component-all.json +4 -2
- package/tests/fixtures/components.json +4 -2
- package/tests/fixtures/effects.json +12 -6
- package/tests/fixtures/full.json +8 -4
- package/tests/fixtures/map.json +2 -1
- package/tests/fixtures/md/dialogue.md +12 -0
- package/tests/fixtures/md/edge-cases.md +9 -0
- package/tests/fixtures/scenes.json +4 -2
- package/tests/fixtures/subtitle.json +6 -3
- package/tests/fixtures/subvideo.json +11 -6
- package/tests/fixtures/templates/courseware.md +61 -4
- package/tests/fixtures/tween-visual.json +2 -6
- package/tests/fixtures/video-series.json +6 -14
- package/tests/md-descriptive.test.ts +170 -0
- package/tests/render.test.ts +32 -16
- package/tests/schema.test.ts +6 -3
- package/tests/server.test.ts +9 -6
- package/tests/utils.ts +4 -4
- package/docs/dynamic-components.md +0 -191
- package/docs/templates.md +0 -52
- package/scripts/artlist-dl.mjs +0 -190
- package/src/player/label-server.mjs +0 -599
|
@@ -261,6 +261,117 @@ export async function resolveMediaDurations(
|
|
|
261
261
|
return clone;
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
// ── Dialogue expansion ──────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Detect if a script contains multi-turn dialogue in `SpeakerName: text` format.
|
|
268
|
+
*
|
|
269
|
+
* A script is a "dialogue" if at least 2 lines match the pattern `Name: text`.
|
|
270
|
+
* Single-line `SpeakerName: text` is NOT treated as dialogue — it's just a
|
|
271
|
+
* regular script with a speaker annotation.
|
|
272
|
+
*
|
|
273
|
+
* Example dialogue:
|
|
274
|
+
* ```
|
|
275
|
+
* Ray: Hello everyone and welcome
|
|
276
|
+
* Alice: Good day to you all
|
|
277
|
+
* Ray: Let's get started
|
|
278
|
+
* ```
|
|
279
|
+
*
|
|
280
|
+
* Each line becomes a separate audio node with:
|
|
281
|
+
* - `script`: just the text (without the "SpeakerName: " prefix)
|
|
282
|
+
* - `speaker`: the speaker name
|
|
283
|
+
*
|
|
284
|
+
* The dialogue lines are wrapped in a series container so they play sequentially
|
|
285
|
+
* regardless of the parent scene's layout.
|
|
286
|
+
*/
|
|
287
|
+
export function parseDialogueLines(script: string): Array<{ speaker: string; text: string }> {
|
|
288
|
+
const lines = script.split("\n").map(l => l.trim()).filter(Boolean);
|
|
289
|
+
const dialoguePattern = /^([\w\u4e00-\u9fff][\w\s\u4e00-\u9fff]*?):\s*(.+)$/;
|
|
290
|
+
|
|
291
|
+
const result: Array<{ speaker: string; text: string }> = [];
|
|
292
|
+
for (const line of lines) {
|
|
293
|
+
const match = line.match(dialoguePattern);
|
|
294
|
+
if (!match) return []; // Any non-matching line invalidates the dialogue format
|
|
295
|
+
result.push({ speaker: match[1]!.trim(), text: match[2]!.trim() });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Require at least 2 lines to be considered a dialogue
|
|
299
|
+
return result.length >= 2 ? result : [];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Walk the descriptive tree and expand multi-turn dialogue-formatted scripts
|
|
304
|
+
* into sequential per-line audio nodes.
|
|
305
|
+
*
|
|
306
|
+
* This runs BEFORE resolveScripts so each dialogue line gets its own TTS
|
|
307
|
+
* generation with the correct per-speaker voice.
|
|
308
|
+
*/
|
|
309
|
+
export function resolveDialogue(root: DescriptiveRoot): DescriptiveRoot {
|
|
310
|
+
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
311
|
+
|
|
312
|
+
// Collect dialogue nodes for expansion (walking and modifying simultaneously is tricky)
|
|
313
|
+
const expansions: Array<{
|
|
314
|
+
parent: any;
|
|
315
|
+
index: number;
|
|
316
|
+
originalNode: any;
|
|
317
|
+
lines: Array<{ speaker: string; text: string }>;
|
|
318
|
+
}> = [];
|
|
319
|
+
|
|
320
|
+
walkDown(clone as any, (node, parent) => {
|
|
321
|
+
if (node.type !== "audio") return;
|
|
322
|
+
if (!node.script || typeof node.script !== "string") return;
|
|
323
|
+
if (node.src) return; // Already has a real source
|
|
324
|
+
|
|
325
|
+
const lines = parseDialogueLines(node.script);
|
|
326
|
+
if (lines.length < 2) return; // Not a dialogue
|
|
327
|
+
|
|
328
|
+
if (!parent || !Array.isArray(parent.children)) return;
|
|
329
|
+
const idx = parent.children.indexOf(node);
|
|
330
|
+
if (idx === -1) return;
|
|
331
|
+
|
|
332
|
+
expansions.push({ parent, index: idx, originalNode: node, lines });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
if (expansions.length === 0) return root;
|
|
336
|
+
|
|
337
|
+
// Apply expansions in reverse index order so splice indices stay valid
|
|
338
|
+
expansions.sort((a, b) => b.index - a.index);
|
|
339
|
+
|
|
340
|
+
for (const { parent, index, originalNode, lines } of expansions) {
|
|
341
|
+
const dialogueNodes: any[] = [];
|
|
342
|
+
|
|
343
|
+
for (let i = 0; i < lines.length; i++) {
|
|
344
|
+
const line = lines[i]!;
|
|
345
|
+
const { speaker, text } = line;
|
|
346
|
+
const lineNode: any = {
|
|
347
|
+
...originalNode,
|
|
348
|
+
id: originalNode.id ? `${originalNode.id}-line-${i}` : undefined,
|
|
349
|
+
script: text,
|
|
350
|
+
speaker,
|
|
351
|
+
start: 0,
|
|
352
|
+
duration: undefined, // Will be set by TTS duration probing
|
|
353
|
+
};
|
|
354
|
+
delete lineNode.src;
|
|
355
|
+
dialogueNodes.push(lineNode);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Wrap dialogue lines in a series container for sequential playback
|
|
359
|
+
const seriesContainer: any = {
|
|
360
|
+
type: "series",
|
|
361
|
+
id: `${originalNode.id ?? "dialogue"}-series`,
|
|
362
|
+
children: dialogueNodes,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Replace the original node with the series container
|
|
366
|
+
parent.children.splice(index, 1, seriesContainer);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const expanded = expansions.length > 0 ? clone : root;
|
|
370
|
+
const count = expansions.reduce((sum, e) => sum + e.lines.length, 0);
|
|
371
|
+
console.log(` 💬 dialogue: expanded ${expansions.length} script${expansions.length > 1 ? "s" : ""} into ${count} lines`);
|
|
372
|
+
return expanded;
|
|
373
|
+
}
|
|
374
|
+
|
|
264
375
|
// ── Script → TTS → STT ─────────────────────────────────────────────────────
|
|
265
376
|
|
|
266
377
|
export interface ResolveScriptOptions {
|
|
@@ -292,23 +403,30 @@ export async function resolveScripts(
|
|
|
292
403
|
let cacheDirty = false;
|
|
293
404
|
|
|
294
405
|
// Collect all audio nodes that have script text but no src yet
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
walkDown(clone as any, (node, parent) => {
|
|
406
|
+
const allScriptNodes: Array<{ node: any; id: string }> = [];
|
|
407
|
+
walkDown(clone as any, (node) => {
|
|
298
408
|
if (node.type !== "audio") return;
|
|
299
409
|
if (!node.script || typeof node.script !== "string") return;
|
|
300
410
|
if (node.src) return; // already has real source
|
|
301
411
|
const id = node.id ?? `audio-${allScriptNodes.length}`;
|
|
302
|
-
allScriptNodes.push({ node, id
|
|
412
|
+
allScriptNodes.push({ node, id });
|
|
303
413
|
});
|
|
304
414
|
|
|
305
415
|
if (allScriptNodes.length > 0) {
|
|
306
416
|
console.log(` 🔊 TTS: generating ${allScriptNodes.length} script${allScriptNodes.length > 1 ? "s" : ""}...`);
|
|
307
417
|
}
|
|
308
418
|
|
|
309
|
-
for (const { node, id
|
|
310
|
-
//
|
|
311
|
-
|
|
419
|
+
for (const { node, id } of allScriptNodes) {
|
|
420
|
+
// TTS CLI from root config only
|
|
421
|
+
let ttsCli = clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
|
|
422
|
+
|
|
423
|
+
// Per-speaker voice appends extra CLI flags from root voices config
|
|
424
|
+
if (node.speaker && clone.voices) {
|
|
425
|
+
const speakerVoice = clone.voices[node.speaker];
|
|
426
|
+
if (speakerVoice) {
|
|
427
|
+
ttsCli = `${ttsCli} ${speakerVoice}`;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
312
430
|
|
|
313
431
|
// Content-addressed filename: hash of script + CLI, so identical scripts
|
|
314
432
|
// across different source files share the same audio file.
|
|
@@ -318,7 +436,8 @@ export async function resolveScripts(
|
|
|
318
436
|
// Check cache — skip TTS if script + config unchanged AND audio file exists
|
|
319
437
|
const cached = checkCache(cache, `tts:${cacheKey}`, cacheKey);
|
|
320
438
|
let generated: string;
|
|
321
|
-
const
|
|
439
|
+
const speakerLabel = node.speaker ? `${node.speaker}: ` : "";
|
|
440
|
+
const label = `${speakerLabel}${firstWords(node.script, 8)}`;
|
|
322
441
|
if (cached) {
|
|
323
442
|
generated = cached;
|
|
324
443
|
console.log(` ✓ TTS: ${label} (cached)`);
|
|
@@ -378,15 +497,16 @@ export async function resolveSubtitles(
|
|
|
378
497
|
let cacheDirty = false;
|
|
379
498
|
|
|
380
499
|
// Collect { audioSrc, absoluteOffset } from the compiled tree
|
|
381
|
-
const clips: Array<{ audioSrc: string; offset: number }> = [];
|
|
500
|
+
const clips: Array<{ audioSrc: string; offset: number; speaker?: string }> = [];
|
|
382
501
|
|
|
383
502
|
/**
|
|
384
503
|
* Walk an array of sibling nodes, tracking cumulative offset for series layouts.
|
|
385
504
|
*
|
|
386
|
-
* In the compiled stream tree, container nodes (Folder/Scene) don't carry
|
|
387
|
-
* — their position on the timeline is implicit in the
|
|
388
|
-
* renderer which plays each child sequentially
|
|
389
|
-
* This function replicates that logic to compute
|
|
505
|
+
* In the compiled stream tree, container nodes (Folder/Scene) don't carry leaf
|
|
506
|
+
* timing (start/end) — their position on the timeline is implicit in the
|
|
507
|
+
* `<Series>`/`<TransitionSeries>` renderer which plays each child sequentially
|
|
508
|
+
* based on `durationInSeconds`. This function replicates that logic to compute
|
|
509
|
+
* absolute audio offsets.
|
|
390
510
|
*/
|
|
391
511
|
function walkSiblings(
|
|
392
512
|
nodes: any[],
|
|
@@ -404,13 +524,13 @@ export async function resolveSubtitles(
|
|
|
404
524
|
// In a series, each child starts after all previous siblings' durations.
|
|
405
525
|
// In parallel, all children share the parent offset.
|
|
406
526
|
const nodeStart = parentIsSeries ? seriesOffset : parentOffset;
|
|
407
|
-
// Leaf nodes
|
|
408
|
-
//
|
|
409
|
-
const actionStart = node.
|
|
527
|
+
// Leaf nodes carry a start offset relative to the container start
|
|
528
|
+
// (e.g., parallel layout with staggered start) directly on the base field.
|
|
529
|
+
const actionStart = node.start ?? 0;
|
|
410
530
|
const effectiveOffset = nodeStart + actionStart;
|
|
411
531
|
|
|
412
532
|
if (node.type === "audio" && node.src) {
|
|
413
|
-
clips.push({ audioSrc: node.src, offset: effectiveOffset });
|
|
533
|
+
clips.push({ audioSrc: node.src, offset: effectiveOffset, speaker: node.speaker });
|
|
414
534
|
}
|
|
415
535
|
|
|
416
536
|
// Recurse into children — determine their layout from the node type
|
|
@@ -449,7 +569,7 @@ export async function resolveSubtitles(
|
|
|
449
569
|
}
|
|
450
570
|
}
|
|
451
571
|
|
|
452
|
-
// Walk the compiled tree (with
|
|
572
|
+
// Walk the compiled tree (with base timing) for correct absolute offsets;
|
|
453
573
|
// fall back to the descriptive tree if no compiled tree provided.
|
|
454
574
|
const treeToWalk = options.compiled ?? (clone as any);
|
|
455
575
|
walkSiblings(
|
|
@@ -466,7 +586,7 @@ export async function resolveSubtitles(
|
|
|
466
586
|
|
|
467
587
|
let sttFailedCount = 0;
|
|
468
588
|
|
|
469
|
-
for (const { audioSrc, offset } of clips) {
|
|
589
|
+
for (const { audioSrc, offset, speaker } of clips) {
|
|
470
590
|
// Cache key: audio hash + STT CLI string
|
|
471
591
|
const audioHash = existsSync(audioSrc)
|
|
472
592
|
? createHash("sha1").update(readFileSync(audioSrc)).digest("hex").slice(0, 12)
|
|
@@ -518,7 +638,11 @@ export async function resolveSubtitles(
|
|
|
518
638
|
const sec = (s % 60).toFixed(3).padStart(6, "0");
|
|
519
639
|
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${sec}`;
|
|
520
640
|
};
|
|
521
|
-
|
|
641
|
+
let text = lines.slice(lines.indexOf(tline) + 1).join("\n").trim();
|
|
642
|
+
// Prepend speaker prefix for dialogue cues
|
|
643
|
+
if (speaker) {
|
|
644
|
+
text = `${speaker}: ${text}`;
|
|
645
|
+
}
|
|
522
646
|
mergedLines.push(String(cueIndex++));
|
|
523
647
|
mergedLines.push(`${formatSec(toSec(a) + offset)} --> ${formatSec(toSec(z) + offset)}`);
|
|
524
648
|
mergedLines.push(text, "");
|
|
@@ -864,6 +988,10 @@ export async function resolveAll(
|
|
|
864
988
|
skip: options.skip,
|
|
865
989
|
});
|
|
866
990
|
|
|
991
|
+
// Step 4.5: Expand multi-turn dialogue (SpeakerName: text format)
|
|
992
|
+
// Must run before TTS so each dialogue line gets its own audio node
|
|
993
|
+
result = resolveDialogue(result);
|
|
994
|
+
|
|
867
995
|
// Step 5: Script → TTS
|
|
868
996
|
if (options.scriptOutputDir) {
|
|
869
997
|
result = await resolveScripts(result, {
|
package/src/entry.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { AbsoluteFill, continueRender, delayRender } from "remotion";
|
|
2
|
+
import { AbsoluteFill, continueRender, delayRender, staticFile } from "remotion";
|
|
3
|
+
import { ensureSharedImportMap } from "./utils/component-import-map";
|
|
3
4
|
import { ComposeContext, EventProvider, type ComposeContextValue } from "./context/index";
|
|
4
5
|
|
|
5
6
|
import { FolderLeaf } from "./types/Folder";
|
|
@@ -58,7 +59,12 @@ function useComponentRegistry(imports: unknown): Record<string, React.ComponentT
|
|
|
58
59
|
if (!handleRef.current) {
|
|
59
60
|
handleRef.current = delayRender("Loading component registry: " + imports);
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
+
// Absolute URLs and "/"-rooted paths (preview server) load as-is;
|
|
63
|
+
// relative paths (render CLI stages bundles into publicDir) resolve
|
|
64
|
+
// through staticFile so the Remotion render server can serve them.
|
|
65
|
+
const moduleUrl = /^(https?:|data:|blob:|\/)/.test(imports) ? imports : staticFile(imports);
|
|
66
|
+
ensureSharedImportMap();
|
|
67
|
+
import(/* webpackIgnore: true */ moduleUrl)
|
|
62
68
|
.then((mod: any) => {
|
|
63
69
|
// The bundle exports all components as named exports
|
|
64
70
|
setRegistry(mod.default ?? mod);
|
package/src/player/browser.tsx
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* Browser player entry point.
|
|
3
3
|
* Bundled with esbuild and served by the player server.
|
|
4
4
|
* Renders stream tree JSON using @remotion/player with MarkCut.
|
|
5
|
+
*
|
|
6
|
+
* Supports three modes passed via window.MODE:
|
|
7
|
+
* "label" — label annotation overlay
|
|
8
|
+
* "edit" — edit input with AI auto-reload
|
|
9
|
+
* (default) — plain preview with optional variant bar
|
|
5
10
|
*/
|
|
6
11
|
import * as React from "react";
|
|
7
12
|
import { createRoot } from "react-dom/client";
|
|
@@ -9,6 +14,7 @@ import * as ReactDOM from "react-dom";
|
|
|
9
14
|
import * as Remotion from "remotion";
|
|
10
15
|
import { Player } from "@remotion/player";
|
|
11
16
|
import { MarkCut, getDurationInSeconds } from "../entry";
|
|
17
|
+
import { HeaderBar, EditControls, LabelControls, SceneThumbnails, VariantBar } from "./components/index";
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
20
|
* Register all player-bundled packages on a global registry so the import map
|
|
@@ -106,25 +112,58 @@ function PlayerApp() {
|
|
|
106
112
|
const [ready, setReady] = React.useState(false);
|
|
107
113
|
const [error, setError] = React.useState<string | null>(null);
|
|
108
114
|
const [data, setData] = React.useState<any>(null);
|
|
109
|
-
const [refreshKey, setRefreshKey] = React.useState(0);
|
|
110
115
|
const [muted, setMuted] = React.useState(false);
|
|
111
116
|
const [volume, setVolume] = React.useState(1);
|
|
112
|
-
const seekAttemptedRef = React.useRef(false);
|
|
113
117
|
const mountedRef = React.useRef(true);
|
|
114
118
|
|
|
119
|
+
// ── Track current player frame without re-renders ───────────────────
|
|
120
|
+
const currentFrameRef = React.useRef(0);
|
|
121
|
+
const [currentTime, setCurrentTime] = React.useState(0);
|
|
122
|
+
const [activeScene, setActiveScene] = React.useState("");
|
|
123
|
+
|
|
124
|
+
// Fetch scenes for active scene tracking
|
|
125
|
+
React.useEffect(() => {
|
|
126
|
+
fetch("/api/video-info")
|
|
127
|
+
.then((r) => r.json())
|
|
128
|
+
.then((info) => {
|
|
129
|
+
if (info.scenes) {
|
|
130
|
+
// Store scenes globally for time-based lookup
|
|
131
|
+
(window as any).__scenes = info.scenes;
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
.catch(() => {});
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
// Update active scene from currentTime
|
|
138
|
+
React.useEffect(() => {
|
|
139
|
+
const scenes = (window as any).__scenes;
|
|
140
|
+
if (!scenes) return;
|
|
141
|
+
let found = "";
|
|
142
|
+
for (const s of scenes) {
|
|
143
|
+
if (currentTime >= s.start && currentTime < s.end) {
|
|
144
|
+
found = s.name || "";
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
setActiveScene(found);
|
|
149
|
+
}, [currentTime]);
|
|
150
|
+
|
|
151
|
+
// ── Save/restore position across reloads ────────────────────────────
|
|
152
|
+
const pendingSeekRef = React.useRef<number | null>(null);
|
|
153
|
+
|
|
115
154
|
// Parse URL params
|
|
116
155
|
const urlParams = new URLSearchParams(typeof window !== "undefined" ? window.location.search : "");
|
|
117
156
|
const autoPlay = urlParams.get("autoplay") === "true";
|
|
118
157
|
const startAt = parseFloat(urlParams.get("start") || urlParams.get("t") || "0") || 0;
|
|
119
158
|
|
|
120
159
|
// Derive player config from data early so effects can reference them safely.
|
|
121
|
-
// These are computed on every render but only meaningful when data is set.
|
|
122
160
|
const fps = data?.fps ?? 30;
|
|
123
161
|
const durationInSeconds = data ? (getDurationInSeconds(data, true) || 5) : 5;
|
|
124
162
|
const durationInFrames = Math.max(1, Math.ceil(durationInSeconds * fps));
|
|
125
163
|
|
|
126
|
-
|
|
127
|
-
|
|
164
|
+
// ── Load data (does NOT set ready=false to avoid player unmount) ─────
|
|
165
|
+
const loadData = React.useCallback((initial = false) => {
|
|
166
|
+
if (initial) setReady(false);
|
|
128
167
|
const variant = (window as any).VARIANT || "default";
|
|
129
168
|
const url = variant !== "default" ? `/api/video-data?variant=${variant}` : "/api/video-data";
|
|
130
169
|
fetch(url)
|
|
@@ -132,38 +171,55 @@ function PlayerApp() {
|
|
|
132
171
|
.then((json) => {
|
|
133
172
|
const root = json.root || json;
|
|
134
173
|
setData(root);
|
|
135
|
-
setReady(true);
|
|
174
|
+
if (initial) setReady(true);
|
|
136
175
|
})
|
|
137
176
|
.catch((e) => setError(e.message));
|
|
138
177
|
}, []);
|
|
139
178
|
|
|
179
|
+
// ── Initial load ────────────────────────────────────────────────────
|
|
140
180
|
React.useEffect(() => {
|
|
141
|
-
loadData();
|
|
142
|
-
|
|
143
|
-
window.addEventListener("refresh-player", handler);
|
|
144
|
-
return () => { mountedRef.current = false; window.removeEventListener("refresh-player", handler); };
|
|
181
|
+
loadData(true);
|
|
182
|
+
return () => { mountedRef.current = false; };
|
|
145
183
|
}, [loadData]);
|
|
146
184
|
|
|
185
|
+
// ── SSE reload: save position, load new data, restore position ──────
|
|
147
186
|
React.useEffect(() => {
|
|
148
|
-
|
|
149
|
-
|
|
187
|
+
const handler = () => {
|
|
188
|
+
// Save current position before reload
|
|
189
|
+
if (playerRef.current) {
|
|
190
|
+
pendingSeekRef.current = playerRef.current.getCurrentFrame();
|
|
191
|
+
}
|
|
192
|
+
loadData(false);
|
|
193
|
+
};
|
|
194
|
+
window.addEventListener("refresh-player", handler);
|
|
195
|
+
return () => window.removeEventListener("refresh-player", handler);
|
|
196
|
+
}, [loadData]);
|
|
150
197
|
|
|
151
|
-
//
|
|
198
|
+
// ── After data loads, seek to saved position or startAt ─────────────
|
|
152
199
|
React.useEffect(() => {
|
|
153
|
-
if (!ready || !data ||
|
|
154
|
-
|
|
155
|
-
|
|
200
|
+
if (!ready || !data || !playerRef.current) return;
|
|
201
|
+
const targetFrame = pendingSeekRef.current ?? Math.round(startAt * fps);
|
|
202
|
+
if (targetFrame > 0) {
|
|
156
203
|
const timer = setTimeout(() => {
|
|
157
204
|
if (!mountedRef.current || !playerRef.current) return;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
seekAttemptedRef.current = true;
|
|
205
|
+
playerRef.current.seekTo(targetFrame);
|
|
206
|
+
pendingSeekRef.current = null;
|
|
161
207
|
}, 100);
|
|
162
208
|
return () => clearTimeout(timer);
|
|
163
209
|
}
|
|
164
|
-
|
|
210
|
+
pendingSeekRef.current = null;
|
|
165
211
|
}, [ready, data, startAt, fps]);
|
|
166
212
|
|
|
213
|
+
// ── onFrameUpdate: track current time ───────────────────────────────
|
|
214
|
+
const handleFrameUpdate = React.useCallback((frame: number) => {
|
|
215
|
+
currentFrameRef.current = frame;
|
|
216
|
+
// Throttle state updates to ~2fps for scene tracking
|
|
217
|
+
setCurrentTime(prev => {
|
|
218
|
+
const newTime = frame / (data?.fps ?? 30);
|
|
219
|
+
return Math.abs(newTime - prev) > 0.5 ? newTime : prev;
|
|
220
|
+
});
|
|
221
|
+
}, [data?.fps]);
|
|
222
|
+
|
|
167
223
|
// Keyboard shortcuts
|
|
168
224
|
React.useEffect(() => {
|
|
169
225
|
if (!ready || !playerRef.current) return;
|
|
@@ -300,52 +356,120 @@ function PlayerApp() {
|
|
|
300
356
|
return () => window.removeEventListener("keydown", onKey);
|
|
301
357
|
}, [ready, data, fps, durationInFrames, volume]);
|
|
302
358
|
|
|
303
|
-
//
|
|
359
|
+
// Determine mode from global (set via HTML by the server)
|
|
360
|
+
const mode: string =
|
|
361
|
+
(typeof window !== "undefined" ? (window as any).MODE : null) || "preview";
|
|
362
|
+
|
|
363
|
+
// Shared state for header info
|
|
364
|
+
const [editStatus, setEditStatus] = React.useState("");
|
|
365
|
+
const [sseConnected, setSseConnected] = React.useState(false);
|
|
366
|
+
const [labelSceneInfo, setLabelSceneInfo] = React.useState("");
|
|
367
|
+
|
|
368
|
+
// SSE connection — shared across all modes as a server-liveness monitor
|
|
369
|
+
// Edit mode also listens for "reload" messages (auto-refresh on file change)
|
|
370
|
+
const suppressReloadRef = React.useRef(false);
|
|
304
371
|
React.useEffect(() => {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
372
|
+
let evtSource: EventSource | null = null;
|
|
373
|
+
try {
|
|
374
|
+
evtSource = new EventSource("/api/events");
|
|
375
|
+
evtSource.onopen = () => setSseConnected(true);
|
|
376
|
+
evtSource.onmessage = (e: MessageEvent) => {
|
|
377
|
+
try {
|
|
378
|
+
const msg = JSON.parse(e.data);
|
|
379
|
+
if (msg.type === "reload" && !suppressReloadRef.current) {
|
|
380
|
+
window.dispatchEvent(new Event("refresh-player"));
|
|
381
|
+
}
|
|
382
|
+
} catch {}
|
|
383
|
+
};
|
|
384
|
+
evtSource.onerror = () => setSseConnected(false);
|
|
385
|
+
} catch {
|
|
386
|
+
setSseConnected(false);
|
|
387
|
+
}
|
|
388
|
+
return () => {
|
|
389
|
+
evtSource?.close();
|
|
390
|
+
setSseConnected(false);
|
|
309
391
|
};
|
|
310
|
-
});
|
|
392
|
+
}, []);
|
|
311
393
|
|
|
312
394
|
if (error) {
|
|
313
|
-
return
|
|
314
|
-
style: { color: "red", padding: 40, fontFamily: "sans-serif" },
|
|
315
|
-
}, "Error: " + error);
|
|
395
|
+
return <div style={{ color: "red", padding: 40, fontFamily: "sans-serif" }}>Error: {error}</div>;
|
|
316
396
|
}
|
|
317
397
|
|
|
318
398
|
if (!ready) {
|
|
319
|
-
return
|
|
320
|
-
style: { color: "#888", padding: 40, fontFamily: "sans-serif" },
|
|
321
|
-
}, "Loading...");
|
|
399
|
+
return <div style={{ color: "#888", padding: 40, fontFamily: "sans-serif" }}>Loading...</div>;
|
|
322
400
|
}
|
|
323
401
|
|
|
324
402
|
const width = data.width || 1080;
|
|
325
403
|
const height = data.height || 1920;
|
|
326
404
|
|
|
327
|
-
return
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
405
|
+
return (
|
|
406
|
+
<div
|
|
407
|
+
style={{
|
|
408
|
+
width: "100%", height: "100%", background: "#0a0a0a",
|
|
409
|
+
display: "flex", flexDirection: "column", alignItems: "center",
|
|
410
|
+
}}
|
|
411
|
+
>
|
|
412
|
+
{/* ── Header (close button + mode info) ── */}
|
|
413
|
+
<HeaderBar
|
|
414
|
+
mode={mode}
|
|
415
|
+
editStatus={editStatus}
|
|
416
|
+
sseConnected={sseConnected}
|
|
417
|
+
sceneInfo={mode === "label" ? labelSceneInfo : undefined}
|
|
418
|
+
/>
|
|
419
|
+
|
|
420
|
+
{/* ── Variant switcher ── */}
|
|
421
|
+
<VariantBar />
|
|
422
|
+
|
|
423
|
+
{/* ── Player frame ── */}
|
|
424
|
+
<div id="player-frame" style={{ flex: 1, width: "100%", maxWidth: 480, minHeight: 0 }}>
|
|
425
|
+
<Player
|
|
426
|
+
ref={playerRef}
|
|
427
|
+
component={MarkCut}
|
|
428
|
+
inputProps={{ root: data, compose: {} }}
|
|
429
|
+
durationInFrames={durationInFrames}
|
|
430
|
+
fps={fps}
|
|
431
|
+
compositionWidth={width}
|
|
432
|
+
compositionHeight={height}
|
|
433
|
+
style={{ width: "100%", height: "100%" }}
|
|
434
|
+
controls={true}
|
|
435
|
+
showPlaybackRateControl={true}
|
|
436
|
+
allowFullscreen={true}
|
|
437
|
+
clickToPlay={false}
|
|
438
|
+
doubleClickToFullscreen={true}
|
|
439
|
+
autoPlay={autoPlay}
|
|
440
|
+
onFrameUpdate={handleFrameUpdate}
|
|
441
|
+
/>
|
|
442
|
+
</div>
|
|
443
|
+
|
|
444
|
+
{/* ── Scene thumbnails (shared across all modes) ── */}
|
|
445
|
+
<SceneThumbnails
|
|
446
|
+
currentTime={currentTime}
|
|
447
|
+
onSeek={(t) => {
|
|
448
|
+
if (playerRef.current) {
|
|
449
|
+
const frame = Math.round(t * (data?.fps ?? 30));
|
|
450
|
+
playerRef.current.seekTo(frame);
|
|
451
|
+
}
|
|
452
|
+
}}
|
|
453
|
+
/>
|
|
454
|
+
|
|
455
|
+
{/* ── Mode-specific controls ── */}
|
|
456
|
+
{mode === "edit" && (
|
|
457
|
+
<EditControls
|
|
458
|
+
onStatusChange={setEditStatus}
|
|
459
|
+
suppressReloadRef={suppressReloadRef}
|
|
460
|
+
currentTime={currentTime}
|
|
461
|
+
activeScene={activeScene}
|
|
462
|
+
/>
|
|
463
|
+
)}
|
|
464
|
+
{mode === "label" && (
|
|
465
|
+
<LabelControls
|
|
466
|
+
playerRef={playerRef}
|
|
467
|
+
currentTime={currentTime}
|
|
468
|
+
onSceneChange={setLabelSceneInfo}
|
|
469
|
+
/>
|
|
470
|
+
)}
|
|
471
|
+
{/* Preview mode: no extra controls */}
|
|
472
|
+
</div>
|
|
349
473
|
);
|
|
350
474
|
}
|
|
351
475
|
|