@lalalic/markcut 2.4.6 → 2.5.1
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/package.json +1 -1
- package/src/config.mjs +1 -1
- package/src/descriptive/resolve.test.ts +7 -7
- package/src/descriptive/resolve.ts +56 -88
- package/src/player/browser.tsx +6 -1
- package/src/player/bundle/player.js +3 -2
- package/src/player/pipeline.mjs +81 -70
- package/src/player/pipeline.ts +4 -0
- package/src/player/server.mjs +3 -1
- package/src/render/cli.mjs +3 -0
- package/src/types/Subtitle.tsx +6 -5
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -92,7 +92,7 @@ export const DEFAULT_AGENT_CLI = args.cliOverrides.agent || process.env.MARKCUT_
|
|
|
92
92
|
/** Default edit agent CLI. Override via --edit-cli flag or MARKCUT_EDIT_CLI env var. */
|
|
93
93
|
export const DEFAULT_EDIT_CLI = args.cliOverrides.editCli || process.env.MARKCUT_EDIT_CLI || 'npx pi --session-id {sessionid} --system-prompt {systemprompt} -p {prompt}';
|
|
94
94
|
/** Text-to-image CLI. Override via --tti flag or MARKCUT_TTI_CLI env var. */
|
|
95
|
-
export const DEFAULT_TTI_CLI = args.cliOverrides.tti || process.env.MARKCUT_TTI_CLI || 'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps
|
|
95
|
+
export const DEFAULT_TTI_CLI = args.cliOverrides.tti || process.env.MARKCUT_TTI_CLI || 'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps 2 --prompt "{input}" --output "{output}" --seed {seed}';
|
|
96
96
|
/** Text-to-video CLI. Override via --ttv flag or MARKCUT_TTV_CLI env var. */
|
|
97
97
|
export const DEFAULT_TTV_CLI = args.cliOverrides.ttv || process.env.MARKCUT_TTV_CLI || '';
|
|
98
98
|
/** Image-to-text CLI. Override via --itt flag or MARKCUT_ITT_CLI env var. */
|
|
@@ -395,11 +395,10 @@ describe("resolveScripts — additional", () => {
|
|
|
395
395
|
|
|
396
396
|
it("caches TTS output and skips second generateTTS call", async () => {
|
|
397
397
|
let callCount = 0;
|
|
398
|
-
(generateTTS as any).mockImplementation(() => {
|
|
398
|
+
(generateTTS as any).mockImplementation((_script, audioPath) => {
|
|
399
399
|
callCount++;
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
return p;
|
|
400
|
+
writeFileSync(audioPath, "");
|
|
401
|
+
return audioPath;
|
|
403
402
|
});
|
|
404
403
|
|
|
405
404
|
const root: DescriptiveRoot = {
|
|
@@ -412,7 +411,7 @@ describe("resolveScripts — additional", () => {
|
|
|
412
411
|
await resolveScripts(root, { outputDir: tmpDir });
|
|
413
412
|
expect(callCount).toBe(1);
|
|
414
413
|
|
|
415
|
-
// Second call —
|
|
414
|
+
// Second call — filesystem cache: file named by hash already exists
|
|
416
415
|
const result = await resolveScripts(root, { outputDir: tmpDir });
|
|
417
416
|
expect(callCount).toBe(1);
|
|
418
417
|
const scene = result.children[0] as any;
|
|
@@ -552,8 +551,9 @@ describe("resolveSubtitles — additional", () => {
|
|
|
552
551
|
it("merges VTT from multiple audio clips with correct offsets", async () => {
|
|
553
552
|
const a1 = join(tmpDir, "clip1.wav");
|
|
554
553
|
const a2 = join(tmpDir, "clip2.wav");
|
|
555
|
-
|
|
556
|
-
writeFileSync(
|
|
554
|
+
// Different content ensures distinct audio hashes (avoid STT cache collision)
|
|
555
|
+
writeFileSync(a1, "clip1-content");
|
|
556
|
+
writeFileSync(a2, "clip2-content");
|
|
557
557
|
(generateSTT as any)
|
|
558
558
|
.mockImplementationOnce(async () => {
|
|
559
559
|
writeFileSync(join(tmpDir, "clip1.vtt"), "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nfirst\n");
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* has complete duration and renderable children.
|
|
10
10
|
*/
|
|
11
11
|
import { execSync } from "node:child_process";
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, renameSync } from "node:fs";
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { join, dirname, resolve as resolvePath, relative } from "node:path";
|
|
15
15
|
import {
|
|
@@ -28,62 +28,15 @@ import { compileDescriptiveRoot, parseImportsBlock, extractDependencySpecs, reso
|
|
|
28
28
|
import { parseMarkdownDescriptive, parseMarkdownVariants } from "./markdown";
|
|
29
29
|
|
|
30
30
|
// ── Content-hash cache for expensive operations (TTS, STT) ────────────────
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
interface CacheEntry {
|
|
35
|
-
hash: string;
|
|
36
|
-
output: string;
|
|
37
|
-
}
|
|
31
|
+
// Output files are named by content hash — the filesystem IS the cache.
|
|
32
|
+
// If a file named `<hash>.<ext>` exists, it's guaranteed to match its inputs.
|
|
33
|
+
// No manifest file needed.
|
|
38
34
|
|
|
39
35
|
function computeCacheKey(parts: Record<string, unknown>): string {
|
|
40
36
|
const json = JSON.stringify(parts);
|
|
41
37
|
return createHash("sha1").update(json).digest("hex").slice(0, 12);
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
function readCacheManifest(outputDir: string): Record<string, CacheEntry> {
|
|
45
|
-
const manifestPath = join(outputDir, ".cache.json");
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
48
|
-
} catch {
|
|
49
|
-
return {};
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function writeCacheManifest(outputDir: string, manifest: Record<string, CacheEntry>): void {
|
|
54
|
-
const manifestPath = join(outputDir, ".cache.json");
|
|
55
|
-
try {
|
|
56
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
57
|
-
} catch {
|
|
58
|
-
// best-effort
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Returns cached output path if inputs unchanged AND output file still exists.
|
|
64
|
-
* Otherwise returns null (caller should regenerate).
|
|
65
|
-
*/
|
|
66
|
-
function checkCache(
|
|
67
|
-
manifest: Record<string, CacheEntry>,
|
|
68
|
-
key: string,
|
|
69
|
-
cacheKey: string,
|
|
70
|
-
): string | null {
|
|
71
|
-
const entry = manifest[key];
|
|
72
|
-
if (entry?.hash === cacheKey && entry.output && existsSync(entry.output)) {
|
|
73
|
-
return entry.output;
|
|
74
|
-
}
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function updateCache(
|
|
79
|
-
manifest: Record<string, CacheEntry>,
|
|
80
|
-
key: string,
|
|
81
|
-
cacheKey: string,
|
|
82
|
-
output: string,
|
|
83
|
-
): void {
|
|
84
|
-
manifest[key] = { hash: cacheKey, output };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
40
|
// ── Media Duration ─────────────────────────────────────────────────────────
|
|
88
41
|
|
|
89
42
|
/** First N words of a string, safe for log labels. */
|
|
@@ -399,8 +352,6 @@ export async function resolveScripts(
|
|
|
399
352
|
): Promise<DescriptiveRoot> {
|
|
400
353
|
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
401
354
|
mkdirSync(options.outputDir, { recursive: true });
|
|
402
|
-
const cache = readCacheManifest(options.outputDir);
|
|
403
|
-
let cacheDirty = false;
|
|
404
355
|
|
|
405
356
|
// Collect all audio nodes that have script text but no src yet
|
|
406
357
|
const allScriptNodes: Array<{ node: any; id: string }> = [];
|
|
@@ -439,21 +390,18 @@ export async function resolveScripts(
|
|
|
439
390
|
const cacheKey = computeCacheKey({ script: node.script, cli: ttsCli });
|
|
440
391
|
const audioPath = join(options.outputDir, `${cacheKey}.mp3`);
|
|
441
392
|
|
|
442
|
-
//
|
|
443
|
-
const cached = checkCache(cache, `tts:${cacheKey}`, cacheKey);
|
|
393
|
+
// Content-addressed cache: file is named by hash — if it exists, it's valid
|
|
444
394
|
let generated: string;
|
|
445
395
|
const speakerLabel = node.speaker ? `${node.speaker}: ` : "";
|
|
446
396
|
const label = `${speakerLabel}${firstWords(node.script, 8)}`;
|
|
447
|
-
if (
|
|
448
|
-
generated =
|
|
397
|
+
if (existsSync(audioPath)) {
|
|
398
|
+
generated = audioPath;
|
|
449
399
|
cacheHits++;
|
|
450
400
|
console.log(` ✓ TTS: ${label} (cached)`);
|
|
451
401
|
} else {
|
|
452
402
|
console.log(` 🔊 TTS (${scriptsDone}/${totalScripts}): ${label}...`);
|
|
453
403
|
generated = generateTTS(node.script, audioPath, ttsCli);
|
|
454
404
|
if (generated) {
|
|
455
|
-
updateCache(cache, `tts:${cacheKey}`, cacheKey, generated);
|
|
456
|
-
cacheDirty = true;
|
|
457
405
|
console.log(` ✓ TTS (${scriptsDone}/${totalScripts}): ${label}`);
|
|
458
406
|
} else {
|
|
459
407
|
console.warn(` ⚠ TTS produced no audio for "${label}". Audio will have no source. Check root.tts config.`);
|
|
@@ -466,9 +414,7 @@ export async function resolveScripts(
|
|
|
466
414
|
delete node.script;
|
|
467
415
|
}
|
|
468
416
|
|
|
469
|
-
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
470
417
|
if (totalScripts > 0) {
|
|
471
|
-
try { writeFileSync(join(options.outputDir, ".cache.json"), JSON.stringify(cache, null, 2), "utf-8"); } catch {}
|
|
472
418
|
const ttsElapsed = Math.round((Date.now() - ttsStart) / 1000);
|
|
473
419
|
const unique = new Set(allScriptNodes.filter(s => s.node.src).map(s => s.node.src)).size;
|
|
474
420
|
console.log(` ✅ TTS: ${unique} unique audio${unique > 1 ? "s" : ""} (${scriptsDone} nodes) in ${ttsElapsed}s (${cacheHits} cached)`);
|
|
@@ -502,8 +448,6 @@ export async function resolveSubtitles(
|
|
|
502
448
|
if (!sttCli) return clone;
|
|
503
449
|
|
|
504
450
|
mkdirSync(options.outputDir, { recursive: true });
|
|
505
|
-
const cache = readCacheManifest(options.outputDir);
|
|
506
|
-
let cacheDirty = false;
|
|
507
451
|
|
|
508
452
|
// Collect { audioSrc, absoluteOffset } from the compiled tree
|
|
509
453
|
const clips: Array<{ audioSrc: string; offset: number; speaker?: string }> = [];
|
|
@@ -596,30 +540,27 @@ export async function resolveSubtitles(
|
|
|
596
540
|
let sttFailedCount = 0;
|
|
597
541
|
|
|
598
542
|
for (const { audioSrc, offset, speaker } of clips) {
|
|
599
|
-
//
|
|
543
|
+
// Content-addressed STT cache: hash of (audio content + CLI) → hash-named VTT
|
|
600
544
|
const audioHash = existsSync(audioSrc)
|
|
601
545
|
? createHash("sha1").update(readFileSync(audioSrc)).digest("hex").slice(0, 12)
|
|
602
546
|
: audioSrc;
|
|
603
547
|
const sttCacheKey = computeCacheKey({ audioHash, cli: sttCli });
|
|
604
|
-
const sttKey = `stt:${audioSrc.split("/").pop()}`;
|
|
605
548
|
const clipName = audioSrc.split("/").pop()!;
|
|
549
|
+
const expectedVtt = join(options.outputDir, `${sttCacheKey}.vtt`);
|
|
606
550
|
|
|
607
551
|
let vttPath: string | null = null;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if (cachedVtt) {
|
|
611
|
-
vttPath = cachedVtt;
|
|
552
|
+
if (existsSync(expectedVtt)) {
|
|
553
|
+
vttPath = expectedVtt;
|
|
612
554
|
} else {
|
|
613
555
|
try {
|
|
614
556
|
await generateSTT(audioSrc, options.outputDir, sttCli);
|
|
615
|
-
//
|
|
557
|
+
// Whisper names VTT after input audio file; rename to hash-based name
|
|
616
558
|
const base = audioSrc.replace(/\.wav$/, "").replace(/\.mp3$/, "");
|
|
617
559
|
const name = base.split("/").pop()!;
|
|
618
|
-
const
|
|
619
|
-
if (existsSync(
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
cacheDirty = true;
|
|
560
|
+
const whisperVtt = join(options.outputDir, `${name}.vtt`);
|
|
561
|
+
if (existsSync(whisperVtt)) {
|
|
562
|
+
renameSync(whisperVtt, expectedVtt);
|
|
563
|
+
vttPath = expectedVtt;
|
|
623
564
|
}
|
|
624
565
|
} catch {
|
|
625
566
|
console.warn(` ⚠ STT failed for ${clipName}. Install whisper (pip install openai-whisper) or set root.stt.`);
|
|
@@ -674,7 +615,6 @@ export async function resolveSubtitles(
|
|
|
674
615
|
console.log(` ✅ STT: subtitles ready (${cueIndex - 1} cues)`);
|
|
675
616
|
}
|
|
676
617
|
|
|
677
|
-
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
678
618
|
return clone;
|
|
679
619
|
}
|
|
680
620
|
|
|
@@ -708,8 +648,6 @@ export async function resolveGeneratedMedia(
|
|
|
708
648
|
): Promise<DescriptiveRoot> {
|
|
709
649
|
const clone: DescriptiveRoot = JSON.parse(JSON.stringify(root));
|
|
710
650
|
mkdirSync(options.outputDir, { recursive: true });
|
|
711
|
-
const cache = readCacheManifest(options.outputDir);
|
|
712
|
-
let cacheDirty = false;
|
|
713
651
|
|
|
714
652
|
// Collect all image/video nodes that have prompt but no src
|
|
715
653
|
const genNodes: Array<{
|
|
@@ -753,16 +691,17 @@ export async function resolveGeneratedMedia(
|
|
|
753
691
|
? (clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI)
|
|
754
692
|
: (clone.ttv ?? options.ttvCli ?? DEFAULT_TTV_CLI);
|
|
755
693
|
|
|
694
|
+
// Use the shared seed (from root, options, or content-derived default).
|
|
756
695
|
const seed = options.seed;
|
|
757
696
|
const cacheKey = computeCacheKey({ prompt, cli, type, seed });
|
|
758
697
|
const outputPath = join(options.outputDir, `${cacheKey}.${ext}`);
|
|
759
698
|
|
|
760
|
-
const cached = checkCache(cache, `gen:${cacheKey}`, cacheKey);
|
|
761
699
|
const label = type === "image" ? "TTI" : "TTV";
|
|
762
700
|
const labelText = firstWords(prompt, 8);
|
|
763
701
|
|
|
764
|
-
if
|
|
765
|
-
|
|
702
|
+
// Content-addressed cache: file is named by hash — if it exists, it's valid
|
|
703
|
+
if (existsSync(outputPath)) {
|
|
704
|
+
node.src = resolvePath(outputPath);
|
|
766
705
|
cacheHits++;
|
|
767
706
|
if (done % 5 === 0 || done === total) console.log(progressLine());
|
|
768
707
|
continue;
|
|
@@ -776,8 +715,6 @@ export async function resolveGeneratedMedia(
|
|
|
776
715
|
: generateTTV(prompt, outputPath, cli, ttiCmd, seed);
|
|
777
716
|
if (result) {
|
|
778
717
|
node.src = outputPath;
|
|
779
|
-
updateCache(cache, `gen:${cacheKey}`, cacheKey, outputPath);
|
|
780
|
-
cacheDirty = true;
|
|
781
718
|
console.log(` ✓ ${label} (${done}/${total}): ${labelText}`);
|
|
782
719
|
if (done % 3 === 0 || done === total) console.log(progressLine());
|
|
783
720
|
} else {
|
|
@@ -799,7 +736,6 @@ export async function resolveGeneratedMedia(
|
|
|
799
736
|
const mediaType = genNodes[0]?.type === "video" ? "TTV" : "TTI";
|
|
800
737
|
console.log(` ✅ ${mediaType} complete: ${done} items in ${elapsed}s (${cacheHits} cached)`);
|
|
801
738
|
}
|
|
802
|
-
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
803
739
|
return clone;
|
|
804
740
|
}
|
|
805
741
|
|
|
@@ -830,6 +766,10 @@ export interface ResolveAllOptions extends ResolveMediaOptions {
|
|
|
830
766
|
* When set, include nodes parse their target .md files with variant awareness
|
|
831
767
|
* and apply variant overrides (zh-src → src, bare `zh` key → primary content). */
|
|
832
768
|
variants?: string[];
|
|
769
|
+
/** Path to the source file (.md or .json). When set and root.seed is missing,
|
|
770
|
+
* a deterministic seed is auto-generated and written back to the source file,
|
|
771
|
+
* making it stable across restarts for cache consistency. */
|
|
772
|
+
sourcePath?: string;
|
|
833
773
|
}
|
|
834
774
|
|
|
835
775
|
/**
|
|
@@ -993,6 +933,37 @@ export async function resolveAll(
|
|
|
993
933
|
): Promise<DescriptiveRoot> {
|
|
994
934
|
let result = root;
|
|
995
935
|
|
|
936
|
+
// ── Auto-seed: persist a deterministic seed into the source file ───────────
|
|
937
|
+
// When no seed is set by the user (root.seed) or caller (options.seed),
|
|
938
|
+
// generate one from the content hash and write it back to the source file.
|
|
939
|
+
// This makes the seed visible and stable across restarts — the cache key
|
|
940
|
+
// never changes unless the content or the user intentionally edits the seed.
|
|
941
|
+
if (result.seed == null && options.seed == null && options.sourcePath) {
|
|
942
|
+
const autoSeed = parseInt(computeCacheKey(result as any).slice(0, 8), 16);
|
|
943
|
+
result = { ...result, seed: autoSeed };
|
|
944
|
+
try {
|
|
945
|
+
const raw = readFileSync(options.sourcePath, "utf-8");
|
|
946
|
+
if (options.sourcePath.endsWith(".md")) {
|
|
947
|
+
// Insert seed: <N> after the first line (# video) or after existing header
|
|
948
|
+
const lines = raw.split("\n");
|
|
949
|
+
const headerEnd = lines.findIndex((l) => l.trim() === "" || l.startsWith("##"));
|
|
950
|
+
const insertAt = headerEnd > 1 ? headerEnd : Math.min(1, lines.length);
|
|
951
|
+
lines.splice(insertAt, 0, `seed:${autoSeed}`);
|
|
952
|
+
writeFileSync(options.sourcePath, lines.join("\n"), "utf-8");
|
|
953
|
+
} else if (options.sourcePath.endsWith(".json")) {
|
|
954
|
+
const parsed = JSON.parse(raw);
|
|
955
|
+
const target = parsed.root || parsed;
|
|
956
|
+
target.seed = autoSeed;
|
|
957
|
+
writeFileSync(options.sourcePath, JSON.stringify(parsed, null, 2), "utf-8");
|
|
958
|
+
}
|
|
959
|
+
console.log(` 🌱 Auto-seed: ${autoSeed} → ${options.sourcePath}`);
|
|
960
|
+
} catch (e: any) {
|
|
961
|
+
console.warn(` ⚠ Could not write seed to source file: ${e.message}`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
const contentSeed = result.seed ?? options.seed ?? 0;
|
|
965
|
+
result = { ...result, seed: contentSeed };
|
|
966
|
+
|
|
996
967
|
// Step 0: Resolve includes (pre-compile referenced .md files to JSON with accurate durations)
|
|
997
968
|
result = await resolveIncludes(result, options);
|
|
998
969
|
|
|
@@ -1012,15 +983,12 @@ export async function resolveAll(
|
|
|
1012
983
|
result = await resolveMediaSrcs(result, { baseDir: options.baseDir });
|
|
1013
984
|
|
|
1014
985
|
// Step 3: Generate images/videos from prompts before probing durations.
|
|
1015
|
-
// Use root.seed if set; otherwise inherit from options (for includes).
|
|
1016
|
-
// Auto-generate a random seed so {seed} in the CLI template always gets a value.
|
|
1017
|
-
const generationSeed = result.seed ?? options.seed ?? Math.floor(Math.random() * 2_147_483_647);
|
|
1018
986
|
if (options.mediaOutputDir) {
|
|
1019
987
|
result = await resolveGeneratedMedia(result, {
|
|
1020
988
|
outputDir: options.mediaOutputDir,
|
|
1021
989
|
ttiCli: options.ttiCli,
|
|
1022
990
|
ttvCli: options.ttvCli,
|
|
1023
|
-
seed:
|
|
991
|
+
seed: contentSeed,
|
|
1024
992
|
});
|
|
1025
993
|
}
|
|
1026
994
|
|
package/src/player/browser.tsx
CHANGED
|
@@ -165,6 +165,11 @@ function PlayerApp() {
|
|
|
165
165
|
const durationInSeconds = data ? (getDurationInSeconds(data, true) || 5) : 5;
|
|
166
166
|
const durationInFrames = Math.max(1, Math.ceil(durationInSeconds * fps));
|
|
167
167
|
|
|
168
|
+
// Memoize inputProps to avoid Remotion Player composition restart on every render.
|
|
169
|
+
// Without memoization, inputProps creates a new object reference on each render,
|
|
170
|
+
// causing ~10 composition restarts/sec (audio nodes remount → audio jumps back).
|
|
171
|
+
const inputProps = React.useMemo(() => ({ root: data, compose: {} }), [data]);
|
|
172
|
+
|
|
168
173
|
// ── Load data (does NOT set ready=false to avoid player unmount) ─────
|
|
169
174
|
const loadData = React.useCallback((initial = false) => {
|
|
170
175
|
if (initial) setReady(false);
|
|
@@ -446,7 +451,7 @@ function PlayerApp() {
|
|
|
446
451
|
<Player
|
|
447
452
|
ref={playerRef}
|
|
448
453
|
component={MarkCut}
|
|
449
|
-
inputProps={
|
|
454
|
+
inputProps={inputProps}
|
|
450
455
|
durationInFrames={durationInFrames}
|
|
451
456
|
fps={fps}
|
|
452
457
|
compositionWidth={width}
|
|
@@ -45998,9 +45998,9 @@ function SubtitleOverlay({ subtitle }) {
|
|
|
45998
45998
|
finish();
|
|
45999
45999
|
};
|
|
46000
46000
|
}, [subtitle.src]);
|
|
46001
|
+
const cueGroups = React46.useMemo(() => cues ? groupConsecutiveCues(cues) : [], [cues]);
|
|
46001
46002
|
if (!cues) return null;
|
|
46002
46003
|
const boxCss = subtitle.style ? cssJS(subtitle.style) : {};
|
|
46003
|
-
const cueGroups = React46.useMemo(() => groupConsecutiveCues(cues), [cues]);
|
|
46004
46004
|
return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)("div", { className: `${subtitle.type || "default"} subtitle-overlay`, style: { ...DEFAULT_BOX_STYLE, ...boxCss }, children: cueGroups.map((group, gi) => /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(
|
|
46005
46005
|
CueFrame,
|
|
46006
46006
|
{
|
|
@@ -60558,6 +60558,7 @@ function PlayerApp() {
|
|
|
60558
60558
|
const fps = data2?.fps ?? 30;
|
|
60559
60559
|
const durationInSeconds = data2 ? getDurationInSeconds(data2, true) || 5 : 5;
|
|
60560
60560
|
const durationInFrames = Math.max(1, Math.ceil(durationInSeconds * fps));
|
|
60561
|
+
const inputProps = React55.useMemo(() => ({ root: data2, compose: {} }), [data2]);
|
|
60561
60562
|
const loadData = React55.useCallback((initial = false) => {
|
|
60562
60563
|
if (initial) setReady(false);
|
|
60563
60564
|
const variant = window.VARIANT || "default";
|
|
@@ -60793,7 +60794,7 @@ function PlayerApp() {
|
|
|
60793
60794
|
{
|
|
60794
60795
|
ref: playerRef,
|
|
60795
60796
|
component: MarkCut,
|
|
60796
|
-
inputProps
|
|
60797
|
+
inputProps,
|
|
60797
60798
|
durationInFrames,
|
|
60798
60799
|
fps,
|
|
60799
60800
|
compositionWidth: width,
|
package/src/player/pipeline.mjs
CHANGED
|
@@ -964,7 +964,7 @@ init_compiler();
|
|
|
964
964
|
|
|
965
965
|
// src/descriptive/resolve.ts
|
|
966
966
|
import { execSync as execSync2 } from "node:child_process";
|
|
967
|
-
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "node:fs";
|
|
967
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
968
968
|
import { createHash } from "node:crypto";
|
|
969
969
|
import { join, dirname as dirname2, resolve as resolvePath, relative } from "node:path";
|
|
970
970
|
|
|
@@ -985,7 +985,7 @@ var args = (function parseArgs(argv) {
|
|
|
985
985
|
"--tti": "tti",
|
|
986
986
|
"--ttv": "ttv"
|
|
987
987
|
};
|
|
988
|
-
const args2 = { command: "", file: "", output: "", forceNew: false, verbose: false, label: false, edit: false, noBrowser: false, chat: false, port: 3001, compile: false, cli: false, showClis: false, scriptOutputDir: "", mediaOutputDir: "", variant: [], cliOverrides: {} };
|
|
988
|
+
const args2 = { command: "", file: "", output: "", forceNew: false, verbose: false, dev: false, label: false, edit: false, noBrowser: false, chat: false, port: 3001, compile: false, cli: false, showClis: false, scriptOutputDir: "", mediaOutputDir: "", variant: [], cliOverrides: {} };
|
|
989
989
|
let i = 2;
|
|
990
990
|
if (argv[i]) args2.command = argv[i++];
|
|
991
991
|
if (argv[i] && !argv[i].startsWith("--")) args2.file = argv[i++];
|
|
@@ -999,6 +999,7 @@ var args = (function parseArgs(argv) {
|
|
|
999
999
|
else if (flag === "--compile") args2.compile = true;
|
|
1000
1000
|
else if (flag === "--force-new") args2.forceNew = true;
|
|
1001
1001
|
else if (flag === "--verbose") args2.verbose = true;
|
|
1002
|
+
else if (flag === "--dev") args2.dev = true;
|
|
1002
1003
|
else if (flag === "--label") args2.label = true;
|
|
1003
1004
|
else if (flag === "--edit") args2.edit = true;
|
|
1004
1005
|
else if (flag === "--no-browser") args2.noBrowser = true;
|
|
@@ -1022,7 +1023,7 @@ var DEFAULT_STT_CLI = args.cliOverrides.stt || process.env.MARKCUT_STT_CLI || 'u
|
|
|
1022
1023
|
var DEFAULT_TTS_CLI = args.cliOverrides.tts || process.env.MARKCUT_TTS_CLI || 'uvx edge-tts --voice "en-US-GuyNeural" --text "{input}" --write-media "{output}"';
|
|
1023
1024
|
var DEFAULT_AGENT_CLI = args.cliOverrides.agent || process.env.MARKCUT_AGENT_CLI || "npx pi -p {prompt}";
|
|
1024
1025
|
var DEFAULT_EDIT_CLI = args.cliOverrides.editCli || process.env.MARKCUT_EDIT_CLI || "npx pi --session-id {sessionid} --system-prompt {systemprompt} -p {prompt}";
|
|
1025
|
-
var DEFAULT_TTI_CLI = args.cliOverrides.tti || process.env.MARKCUT_TTI_CLI || 'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps
|
|
1026
|
+
var DEFAULT_TTI_CLI = args.cliOverrides.tti || process.env.MARKCUT_TTI_CLI || 'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps 2 --prompt "{input}" --output "{output}" --seed {seed}';
|
|
1026
1027
|
var DEFAULT_TTV_CLI = args.cliOverrides.ttv || process.env.MARKCUT_TTV_CLI || "";
|
|
1027
1028
|
var DEFAULT_ITT_CLI = args.cliOverrides.itt || process.env.MARKCUT_ITT_CLI || 'uvx --from mlx-vlm mlx_vlm.generate --model mlx-community/MiniCPM-V-4.6-bf16 --max-tokens 2048 --prompt "{prompt}" --image {input} --temperature 0.0 --thinking-mode disabled';
|
|
1028
1029
|
var DEFAULT_VTT_CLI = args.cliOverrides.vtt || process.env.MARKCUT_VTT_CLI || `uvx --from mlx-vlm mlx_vlm.generate --model mlx-community/MiniCPM-V-4.6-bf16 --max-tokens 2048 --prompt "{prompt}" --video {input} --temperature 0.0 --processor-kwargs '{"max_num_frames": 32, "stack_frames": 1, "max_slice_nums": 1, "use_image_id": false}'`;
|
|
@@ -10133,6 +10134,7 @@ function preserveVariantAttrs(node2, attrs) {
|
|
|
10133
10134
|
"duration",
|
|
10134
10135
|
"startFrom",
|
|
10135
10136
|
"endAt",
|
|
10137
|
+
"loop",
|
|
10136
10138
|
"width",
|
|
10137
10139
|
"height",
|
|
10138
10140
|
"fit",
|
|
@@ -10645,31 +10647,6 @@ function computeCacheKey(parts) {
|
|
|
10645
10647
|
const json = JSON.stringify(parts);
|
|
10646
10648
|
return createHash("sha1").update(json).digest("hex").slice(0, 12);
|
|
10647
10649
|
}
|
|
10648
|
-
function readCacheManifest(outputDir) {
|
|
10649
|
-
const manifestPath = join(outputDir, ".cache.json");
|
|
10650
|
-
try {
|
|
10651
|
-
return JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
10652
|
-
} catch {
|
|
10653
|
-
return {};
|
|
10654
|
-
}
|
|
10655
|
-
}
|
|
10656
|
-
function writeCacheManifest(outputDir, manifest) {
|
|
10657
|
-
const manifestPath = join(outputDir, ".cache.json");
|
|
10658
|
-
try {
|
|
10659
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8");
|
|
10660
|
-
} catch {
|
|
10661
|
-
}
|
|
10662
|
-
}
|
|
10663
|
-
function checkCache(manifest, key, cacheKey) {
|
|
10664
|
-
const entry = manifest[key];
|
|
10665
|
-
if (entry?.hash === cacheKey && entry.output && existsSync2(entry.output)) {
|
|
10666
|
-
return entry.output;
|
|
10667
|
-
}
|
|
10668
|
-
return null;
|
|
10669
|
-
}
|
|
10670
|
-
function updateCache(manifest, key, cacheKey, output) {
|
|
10671
|
-
manifest[key] = { hash: cacheKey, output };
|
|
10672
|
-
}
|
|
10673
10650
|
function firstWords(text3, n) {
|
|
10674
10651
|
if (!text3) return "";
|
|
10675
10652
|
return text3.split(/\s+/).slice(0, n).join(" ").replace(/[^a-zA-Z0-9\u4e00-\u9fff\s-]/g, "").slice(0, 60) || text3.slice(0, 60);
|
|
@@ -10833,8 +10810,6 @@ function resolveDialogue(root) {
|
|
|
10833
10810
|
async function resolveScripts(root, options) {
|
|
10834
10811
|
const clone = JSON.parse(JSON.stringify(root));
|
|
10835
10812
|
mkdirSync2(options.outputDir, { recursive: true });
|
|
10836
|
-
const cache = readCacheManifest(options.outputDir);
|
|
10837
|
-
let cacheDirty = false;
|
|
10838
10813
|
const allScriptNodes = [];
|
|
10839
10814
|
walkDown(clone, (node2) => {
|
|
10840
10815
|
if (node2.type !== "audio") return;
|
|
@@ -10843,10 +10818,15 @@ async function resolveScripts(root, options) {
|
|
|
10843
10818
|
const id = node2.id ?? `audio-${allScriptNodes.length}`;
|
|
10844
10819
|
allScriptNodes.push({ node: node2, id });
|
|
10845
10820
|
});
|
|
10846
|
-
|
|
10847
|
-
|
|
10821
|
+
const totalScripts = allScriptNodes.length;
|
|
10822
|
+
let scriptsDone = 0;
|
|
10823
|
+
let cacheHits = 0;
|
|
10824
|
+
const ttsStart = Date.now();
|
|
10825
|
+
if (totalScripts > 0) {
|
|
10826
|
+
console.log(` \u{1F50A} TTS: generating ${totalScripts} script${totalScripts > 1 ? "s" : ""}...`);
|
|
10848
10827
|
}
|
|
10849
10828
|
for (const { node: node2, id } of allScriptNodes) {
|
|
10829
|
+
scriptsDone++;
|
|
10850
10830
|
let ttsCli = clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
|
|
10851
10831
|
if (node2.speaker && clone.voices) {
|
|
10852
10832
|
const speakerVoice = clone.voices[node2.speaker];
|
|
@@ -10856,19 +10836,18 @@ async function resolveScripts(root, options) {
|
|
|
10856
10836
|
}
|
|
10857
10837
|
const cacheKey = computeCacheKey({ script: node2.script, cli: ttsCli });
|
|
10858
10838
|
const audioPath = join(options.outputDir, `${cacheKey}.mp3`);
|
|
10859
|
-
const cached = checkCache(cache, `tts:${cacheKey}`, cacheKey);
|
|
10860
10839
|
let generated;
|
|
10861
10840
|
const speakerLabel = node2.speaker ? `${node2.speaker}: ` : "";
|
|
10862
10841
|
const label = `${speakerLabel}${firstWords(node2.script, 8)}`;
|
|
10863
|
-
if (
|
|
10864
|
-
generated =
|
|
10842
|
+
if (existsSync2(audioPath)) {
|
|
10843
|
+
generated = audioPath;
|
|
10844
|
+
cacheHits++;
|
|
10865
10845
|
console.log(` \u2713 TTS: ${label} (cached)`);
|
|
10866
10846
|
} else {
|
|
10847
|
+
console.log(` \u{1F50A} TTS (${scriptsDone}/${totalScripts}): ${label}...`);
|
|
10867
10848
|
generated = generateTTS(node2.script, audioPath, ttsCli);
|
|
10868
10849
|
if (generated) {
|
|
10869
|
-
|
|
10870
|
-
cacheDirty = true;
|
|
10871
|
-
console.log(` \u2713 TTS: ${label}`);
|
|
10850
|
+
console.log(` \u2713 TTS (${scriptsDone}/${totalScripts}): ${label}`);
|
|
10872
10851
|
} else {
|
|
10873
10852
|
console.warn(` \u26A0 TTS produced no audio for "${label}". Audio will have no source. Check root.tts config.`);
|
|
10874
10853
|
}
|
|
@@ -10877,14 +10856,10 @@ async function resolveScripts(root, options) {
|
|
|
10877
10856
|
node2.src = resolvePath(generated);
|
|
10878
10857
|
delete node2.script;
|
|
10879
10858
|
}
|
|
10880
|
-
if (
|
|
10881
|
-
|
|
10882
|
-
try {
|
|
10883
|
-
writeFileSync(join(options.outputDir, ".cache.json"), JSON.stringify(cache, null, 2), "utf-8");
|
|
10884
|
-
} catch {
|
|
10885
|
-
}
|
|
10859
|
+
if (totalScripts > 0) {
|
|
10860
|
+
const ttsElapsed = Math.round((Date.now() - ttsStart) / 1e3);
|
|
10886
10861
|
const unique = new Set(allScriptNodes.filter((s) => s.node.src).map((s) => s.node.src)).size;
|
|
10887
|
-
console.log(` \u2705 TTS: ${unique} unique audio
|
|
10862
|
+
console.log(` \u2705 TTS: ${unique} unique audio${unique > 1 ? "s" : ""} (${scriptsDone} nodes) in ${ttsElapsed}s (${cacheHits} cached)`);
|
|
10888
10863
|
}
|
|
10889
10864
|
return clone;
|
|
10890
10865
|
}
|
|
@@ -10893,8 +10868,6 @@ async function resolveSubtitles(root, options) {
|
|
|
10893
10868
|
const sttCli = clone.stt ?? options.sttCli ?? DEFAULT_STT_CLI;
|
|
10894
10869
|
if (!sttCli) return clone;
|
|
10895
10870
|
mkdirSync2(options.outputDir, { recursive: true });
|
|
10896
|
-
const cache = readCacheManifest(options.outputDir);
|
|
10897
|
-
let cacheDirty = false;
|
|
10898
10871
|
const clips = [];
|
|
10899
10872
|
function walkSiblings(nodes, parentOffset, parentIsSeries, parentTransition, parentTransitionTime) {
|
|
10900
10873
|
let seriesOffset = parentOffset;
|
|
@@ -10943,22 +10916,20 @@ async function resolveSubtitles(root, options) {
|
|
|
10943
10916
|
for (const { audioSrc, offset, speaker } of clips) {
|
|
10944
10917
|
const audioHash = existsSync2(audioSrc) ? createHash("sha1").update(readFileSync(audioSrc)).digest("hex").slice(0, 12) : audioSrc;
|
|
10945
10918
|
const sttCacheKey = computeCacheKey({ audioHash, cli: sttCli });
|
|
10946
|
-
const sttKey = `stt:${audioSrc.split("/").pop()}`;
|
|
10947
10919
|
const clipName = audioSrc.split("/").pop();
|
|
10920
|
+
const expectedVtt = join(options.outputDir, `${sttCacheKey}.vtt`);
|
|
10948
10921
|
let vttPath = null;
|
|
10949
|
-
|
|
10950
|
-
|
|
10951
|
-
vttPath = cachedVtt;
|
|
10922
|
+
if (existsSync2(expectedVtt)) {
|
|
10923
|
+
vttPath = expectedVtt;
|
|
10952
10924
|
} else {
|
|
10953
10925
|
try {
|
|
10954
10926
|
await generateSTT(audioSrc, options.outputDir, sttCli);
|
|
10955
10927
|
const base = audioSrc.replace(/\.wav$/, "").replace(/\.mp3$/, "");
|
|
10956
10928
|
const name = base.split("/").pop();
|
|
10957
|
-
const
|
|
10958
|
-
if (existsSync2(
|
|
10959
|
-
|
|
10960
|
-
|
|
10961
|
-
cacheDirty = true;
|
|
10929
|
+
const whisperVtt = join(options.outputDir, `${name}.vtt`);
|
|
10930
|
+
if (existsSync2(whisperVtt)) {
|
|
10931
|
+
renameSync(whisperVtt, expectedVtt);
|
|
10932
|
+
vttPath = expectedVtt;
|
|
10962
10933
|
}
|
|
10963
10934
|
} catch {
|
|
10964
10935
|
console.warn(` \u26A0 STT failed for ${clipName}. Install whisper (pip install openai-whisper) or set root.stt.`);
|
|
@@ -11008,14 +10979,11 @@ async function resolveSubtitles(root, options) {
|
|
|
11008
10979
|
clone.subtitle = { ...clone.subtitle ?? {}, src: mergedPath };
|
|
11009
10980
|
console.log(` \u2705 STT: subtitles ready (${cueIndex - 1} cues)`);
|
|
11010
10981
|
}
|
|
11011
|
-
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
11012
10982
|
return clone;
|
|
11013
10983
|
}
|
|
11014
10984
|
async function resolveGeneratedMedia(root, options) {
|
|
11015
10985
|
const clone = JSON.parse(JSON.stringify(root));
|
|
11016
10986
|
mkdirSync2(options.outputDir, { recursive: true });
|
|
11017
|
-
const cache = readCacheManifest(options.outputDir);
|
|
11018
|
-
let cacheDirty = false;
|
|
11019
10987
|
const genNodes = [];
|
|
11020
10988
|
walkDown(clone, (node2) => {
|
|
11021
10989
|
if (node2.type !== "image" && node2.type !== "video") return;
|
|
@@ -11024,29 +10992,44 @@ async function resolveGeneratedMedia(root, options) {
|
|
|
11024
10992
|
const id = node2.id ?? `${node2.type}-${genNodes.length}`;
|
|
11025
10993
|
genNodes.push({ node: node2, id, type: node2.type, prompt: node2.prompt });
|
|
11026
10994
|
});
|
|
10995
|
+
const total = genNodes.length;
|
|
10996
|
+
let done = 0;
|
|
10997
|
+
let cacheHits = 0;
|
|
10998
|
+
const startTime = Date.now();
|
|
10999
|
+
function progressLine() {
|
|
11000
|
+
const elapsed = (Date.now() - startTime) / 1e3;
|
|
11001
|
+
const rate = done / Math.max(elapsed, 0.1);
|
|
11002
|
+
const remaining = total - done;
|
|
11003
|
+
const eta = rate > 0 ? Math.round(remaining / rate) : "?";
|
|
11004
|
+
const etaStr = eta === "?" ? "" : `, ~${eta}s remaining`;
|
|
11005
|
+
return ` ${done}/${total} generated (${cacheHits} cached)${etaStr}`;
|
|
11006
|
+
}
|
|
11007
|
+
if (total > 0) {
|
|
11008
|
+
console.log(` \u{1F3A8} Generating ${total} media item${total > 1 ? "s" : ""}...`);
|
|
11009
|
+
}
|
|
11027
11010
|
for (const { node: node2, id, type, prompt } of genNodes) {
|
|
11011
|
+
done++;
|
|
11028
11012
|
const ext = type === "image" ? "png" : "mp4";
|
|
11029
11013
|
const cli = type === "image" ? clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI : clone.ttv ?? options.ttvCli ?? DEFAULT_TTV_CLI;
|
|
11030
11014
|
const seed = options.seed;
|
|
11031
11015
|
const cacheKey = computeCacheKey({ prompt, cli, type, seed });
|
|
11032
11016
|
const outputPath = join(options.outputDir, `${cacheKey}.${ext}`);
|
|
11033
|
-
const cached = checkCache(cache, `gen:${cacheKey}`, cacheKey);
|
|
11034
11017
|
const label = type === "image" ? "TTI" : "TTV";
|
|
11035
11018
|
const labelText = firstWords(prompt, 8);
|
|
11036
|
-
if (
|
|
11037
|
-
node2.src = resolvePath(
|
|
11038
|
-
|
|
11019
|
+
if (existsSync2(outputPath)) {
|
|
11020
|
+
node2.src = resolvePath(outputPath);
|
|
11021
|
+
cacheHits++;
|
|
11022
|
+
if (done % 5 === 0 || done === total) console.log(progressLine());
|
|
11039
11023
|
continue;
|
|
11040
11024
|
}
|
|
11041
11025
|
try {
|
|
11042
|
-
console.log(` \u{1F50A} ${label}: ${labelText}...`);
|
|
11026
|
+
console.log(` \u{1F50A} ${label} (${done}/${total}): ${labelText}...`);
|
|
11043
11027
|
const ttiCmd = clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI;
|
|
11044
11028
|
const result = type === "image" ? generateTTI(prompt, outputPath, cli, seed) : generateTTV(prompt, outputPath, cli, ttiCmd, seed);
|
|
11045
11029
|
if (result) {
|
|
11046
11030
|
node2.src = outputPath;
|
|
11047
|
-
|
|
11048
|
-
|
|
11049
|
-
console.log(` \u2713 ${label}: ${labelText}`);
|
|
11031
|
+
console.log(` \u2713 ${label} (${done}/${total}): ${labelText}`);
|
|
11032
|
+
if (done % 3 === 0 || done === total) console.log(progressLine());
|
|
11050
11033
|
} else {
|
|
11051
11034
|
const hint = cli.includes("echo") ? `No ${label} tool installed. The default CLI just echoes a message \u2014 set root.${type === "image" ? "tti" : "ttv"} to a real generation command.` : `The command ran but produced no output file. Check the CLI template or run the script manually to debug.`;
|
|
11052
11035
|
console.error(` \u26A0 ${label}: "${labelText}" produced no output. ${hint}`);
|
|
@@ -11056,7 +11039,11 @@ async function resolveGeneratedMedia(root, options) {
|
|
|
11056
11039
|
console.error(` \u2717 ${label}: "${labelText}" failed \u2014 ${err.message}. ${hint}`);
|
|
11057
11040
|
}
|
|
11058
11041
|
}
|
|
11059
|
-
if (
|
|
11042
|
+
if (total > 0) {
|
|
11043
|
+
const elapsed = Math.round((Date.now() - startTime) / 1e3);
|
|
11044
|
+
const mediaType = genNodes[0]?.type === "video" ? "TTV" : "TTI";
|
|
11045
|
+
console.log(` \u2705 ${mediaType} complete: ${done} items in ${elapsed}s (${cacheHits} cached)`);
|
|
11046
|
+
}
|
|
11060
11047
|
return clone;
|
|
11061
11048
|
}
|
|
11062
11049
|
async function resolveIncludes(root, options = {}) {
|
|
@@ -11142,6 +11129,30 @@ async function resolveIncludes(root, options = {}) {
|
|
|
11142
11129
|
}
|
|
11143
11130
|
async function resolveAll2(root, options = {}) {
|
|
11144
11131
|
let result = root;
|
|
11132
|
+
if (result.seed == null && options.seed == null && options.sourcePath) {
|
|
11133
|
+
const autoSeed = parseInt(computeCacheKey(result).slice(0, 8), 16);
|
|
11134
|
+
result = { ...result, seed: autoSeed };
|
|
11135
|
+
try {
|
|
11136
|
+
const raw = readFileSync(options.sourcePath, "utf-8");
|
|
11137
|
+
if (options.sourcePath.endsWith(".md")) {
|
|
11138
|
+
const lines = raw.split("\n");
|
|
11139
|
+
const headerEnd = lines.findIndex((l) => l.trim() === "" || l.startsWith("##"));
|
|
11140
|
+
const insertAt = headerEnd > 1 ? headerEnd : Math.min(1, lines.length);
|
|
11141
|
+
lines.splice(insertAt, 0, `seed:${autoSeed}`);
|
|
11142
|
+
writeFileSync(options.sourcePath, lines.join("\n"), "utf-8");
|
|
11143
|
+
} else if (options.sourcePath.endsWith(".json")) {
|
|
11144
|
+
const parsed = JSON.parse(raw);
|
|
11145
|
+
const target = parsed.root || parsed;
|
|
11146
|
+
target.seed = autoSeed;
|
|
11147
|
+
writeFileSync(options.sourcePath, JSON.stringify(parsed, null, 2), "utf-8");
|
|
11148
|
+
}
|
|
11149
|
+
console.log(` \u{1F331} Auto-seed: ${autoSeed} \u2192 ${options.sourcePath}`);
|
|
11150
|
+
} catch (e) {
|
|
11151
|
+
console.warn(` \u26A0 Could not write seed to source file: ${e.message}`);
|
|
11152
|
+
}
|
|
11153
|
+
}
|
|
11154
|
+
const contentSeed = result.seed ?? options.seed ?? 0;
|
|
11155
|
+
result = { ...result, seed: contentSeed };
|
|
11145
11156
|
result = await resolveIncludes(result, options);
|
|
11146
11157
|
const { resolveAllTemplateVars: resolveAllTemplateVars2 } = await Promise.resolve().then(() => (init_compiler(), compiler_exports));
|
|
11147
11158
|
result = resolveAllTemplateVars2(result, {
|
|
@@ -11151,13 +11162,12 @@ async function resolveAll2(root, options = {}) {
|
|
|
11151
11162
|
variant: options.variants?.[0] ?? "video"
|
|
11152
11163
|
});
|
|
11153
11164
|
result = await resolveMediaSrcs(result, { baseDir: options.baseDir });
|
|
11154
|
-
const generationSeed = result.seed ?? options.seed ?? Math.floor(Math.random() * 2147483647);
|
|
11155
11165
|
if (options.mediaOutputDir) {
|
|
11156
11166
|
result = await resolveGeneratedMedia(result, {
|
|
11157
11167
|
outputDir: options.mediaOutputDir,
|
|
11158
11168
|
ttiCli: options.ttiCli,
|
|
11159
11169
|
ttvCli: options.ttvCli,
|
|
11160
|
-
seed:
|
|
11170
|
+
seed: contentSeed
|
|
11161
11171
|
});
|
|
11162
11172
|
}
|
|
11163
11173
|
result = await resolveMediaDurations(result, {
|
|
@@ -11201,6 +11211,7 @@ function isDescriptiveRoot(data) {
|
|
|
11201
11211
|
}
|
|
11202
11212
|
async function resolveAndCompile(data, options = {}) {
|
|
11203
11213
|
const resolved = await resolveAll2(data, {
|
|
11214
|
+
sourcePath: options.sourcePath,
|
|
11204
11215
|
baseDir: options.baseDir,
|
|
11205
11216
|
scriptOutputDir: options.scriptOutputDir,
|
|
11206
11217
|
mediaOutputDir: options.mediaOutputDir,
|
package/src/player/pipeline.ts
CHANGED
|
@@ -42,6 +42,9 @@ export interface ResolveAndCompileOptions {
|
|
|
42
42
|
/** Variant chain to apply to include nodes (e.g. ["zh", "tiktok"]).
|
|
43
43
|
* When set, included .md files are parsed with variant awareness. */
|
|
44
44
|
variants?: string[];
|
|
45
|
+
/** Path to the source file (.md or .json). When set and root.seed is missing,
|
|
46
|
+
* a deterministic seed is auto-generated and written back to the source file. */
|
|
47
|
+
sourcePath?: string;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
/**
|
|
@@ -86,6 +89,7 @@ export async function resolveAndCompile(
|
|
|
86
89
|
): Promise<Root> {
|
|
87
90
|
// 1. Async resolve: durations, TTS, STT, includes
|
|
88
91
|
const resolved = await resolveAll(data, {
|
|
92
|
+
sourcePath: options.sourcePath,
|
|
89
93
|
baseDir: options.baseDir,
|
|
90
94
|
scriptOutputDir: options.scriptOutputDir,
|
|
91
95
|
mediaOutputDir: options.mediaOutputDir,
|
package/src/player/server.mjs
CHANGED
|
@@ -436,6 +436,7 @@ async function compileVariant(config, parsed, raw) {
|
|
|
436
436
|
console.log(` 📝 Variant "${config.label}": resolving...`);
|
|
437
437
|
const { resolveAll, compileDescriptiveRoot } = await import("./pipeline.mjs");
|
|
438
438
|
const resolved = await resolveAll(descriptive, {
|
|
439
|
+
sourcePath: VIDEO_JSON,
|
|
439
440
|
baseDir: dirname(VIDEO_JSON),
|
|
440
441
|
scriptOutputDir: TTS_OUTPUT_DIR,
|
|
441
442
|
mediaOutputDir: MEDIA_OUTPUT_DIR,
|
|
@@ -458,6 +459,7 @@ async function compileVariant(config, parsed, raw) {
|
|
|
458
459
|
|
|
459
460
|
console.log(` 📝 Variant "${config.label}": resolving...`);
|
|
460
461
|
compiled = await resolveAndCompile(descriptive, {
|
|
462
|
+
sourcePath: VIDEO_JSON,
|
|
461
463
|
baseDir: dirname(VIDEO_JSON),
|
|
462
464
|
scriptOutputDir: TTS_OUTPUT_DIR,
|
|
463
465
|
mediaOutputDir: MEDIA_OUTPUT_DIR,
|
|
@@ -1007,7 +1009,7 @@ Edit request: ${text}`;
|
|
|
1007
1009
|
}
|
|
1008
1010
|
req.on("close", () => {
|
|
1009
1011
|
sseClients.delete(res);
|
|
1010
|
-
if (
|
|
1012
|
+
if (sseClients.size === 0) {
|
|
1011
1013
|
shutdownTimer = setTimeout(() => {
|
|
1012
1014
|
console.error("\n 🚪 Browser tab closed — shutting down\n");
|
|
1013
1015
|
process.exit(0);
|
package/src/render/cli.mjs
CHANGED
|
@@ -317,6 +317,7 @@ edit=${DEFAULT_EDIT_CLI}`);
|
|
|
317
317
|
const fileDir = dirname(filePath);
|
|
318
318
|
const parsed = parseMarkdownVariants(raw);
|
|
319
319
|
streamTree = await resolveWithVariants(parsed.base, {
|
|
320
|
+
sourcePath: filePath,
|
|
320
321
|
baseDir: fileDir,
|
|
321
322
|
scriptOutputDir: generatedDir(filePath, "tts"),
|
|
322
323
|
mediaOutputDir: generatedDir(filePath, "media"),
|
|
@@ -330,6 +331,7 @@ edit=${DEFAULT_EDIT_CLI}`);
|
|
|
330
331
|
if (isDescriptiveRoot(root)) {
|
|
331
332
|
const fileDir = dirname(filePath);
|
|
332
333
|
streamTree = await resolveAndCompile(root, {
|
|
334
|
+
sourcePath: filePath,
|
|
333
335
|
baseDir: fileDir,
|
|
334
336
|
scriptOutputDir: generatedDir(filePath, "tts"),
|
|
335
337
|
mediaOutputDir: generatedDir(filePath, "media"),
|
|
@@ -518,6 +520,7 @@ function hasScript(root) {
|
|
|
518
520
|
|
|
519
521
|
const baseDir = dirname(filePath);
|
|
520
522
|
const baseOpts = {
|
|
523
|
+
sourcePath: filePath,
|
|
521
524
|
baseDir,
|
|
522
525
|
scriptOutputDir: args.scriptOutputDir || join(baseDir, "assets", "tts"),
|
|
523
526
|
mediaOutputDir: args.mediaOutputDir || join(baseDir, "assets", "media"),
|
package/src/types/Subtitle.tsx
CHANGED
|
@@ -288,13 +288,14 @@ export function SubtitleOverlay({ subtitle }: { subtitle: SubtitleOverlayConfig
|
|
|
288
288
|
};
|
|
289
289
|
}, [subtitle.src]);
|
|
290
290
|
|
|
291
|
-
if (!cues) return null;@
|
|
292
|
-
|
|
293
|
-
const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};@
|
|
294
|
-
|
|
295
291
|
// Group consecutive cues with same plain text (word-level highlighting pattern)
|
|
296
292
|
// so the caption component stays mounted across word transitions — no flash.
|
|
297
|
-
|
|
293
|
+
// Must be called unconditionally (before early return) to keep hooks order stable.
|
|
294
|
+
const cueGroups = React.useMemo(() => cues ? groupConsecutiveCues(cues) : [], [cues]);
|
|
295
|
+
|
|
296
|
+
if (!cues) return null;
|
|
297
|
+
|
|
298
|
+
const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};
|
|
298
299
|
|
|
299
300
|
return (
|
|
300
301
|
<div className={`${subtitle.type || "default"} subtitle-overlay`} style={{ ...DEFAULT_BOX_STYLE, ...boxCss }}>
|