@lalalic/markcut 2.4.0 → 2.4.2
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 +2 -2
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +3 -1
- package/skills/markcut/docs/markdown-descriptive.md +13 -9
- package/src/config.mjs +2 -1
- package/src/descriptive/resolve.ts +43 -16
- package/src/player/bundle/player.js +1 -7
- package/src/render/cli.mjs +67 -32
- package/src/types/Include.tsx +3 -11
- package/src/types/Subtitle.tsx +2 -2
- package/tests/render.test.ts +8 -10
package/AGENTS.md
CHANGED
|
@@ -47,7 +47,7 @@ See [docs/markdown-strict-descriptive.md](docs/markdown-strict-descriptive.md) f
|
|
|
47
47
|
## CLI
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
markcut render <file.json|.md> [--
|
|
50
|
+
markcut render <file.json|.md> [--output path]
|
|
51
51
|
markcut preview <file.json|.md> [--edit] [--label] [--port 3001]
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -176,7 +176,7 @@ All types share base fields from `BaseShape`: `id`, `name`, `title`, `descriptio
|
|
|
176
176
|
### CLI Usage
|
|
177
177
|
|
|
178
178
|
```bash
|
|
179
|
-
node src/render/cli.mjs render <file.json> [--
|
|
179
|
+
node src/render/cli.mjs render <file.json> [--output path]
|
|
180
180
|
node src/render/cli.mjs render --template <id> --data <data.json>
|
|
181
181
|
node src/render/cli.mjs preview <file.json> [--edit] [--label] [--port 3001]
|
|
182
182
|
node src/render/cli.mjs templates
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Write a storyboard in markdown, get a rendered video with TTS narration and subt
|
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
# Render a storyboard to MP4
|
|
7
|
-
npx markcut render storyboard.md
|
|
7
|
+
npx markcut render storyboard.md
|
|
8
8
|
|
|
9
9
|
# Preview with live edit (edit .md file, player auto-reloads)
|
|
10
10
|
npx markcut preview storyboard.md --edit
|
package/package.json
CHANGED
package/skills/markcut/SKILL.md
CHANGED
|
@@ -85,4 +85,6 @@ some common issues (photo or video can't be displayed, audio missing), take belo
|
|
|
85
85
|
- subtitle mismatch
|
|
86
86
|
- sync issues between audio, video, and subtitles
|
|
87
87
|
- don't set duration for script or stream's duration depending on audio script
|
|
88
|
-
- markcut resolver will automatically calculate the duration based on the audio script length
|
|
88
|
+
- markcut resolver will automatically calculate the duration based on the audio script length
|
|
89
|
+
- **don't** rm `.markcut` directory, which served as cache for all generated content. cache will auto update according to the content change. rm `.markcut` will cause all content to be regenerated, which is time consuming and wasteful.
|
|
90
|
+
- put all manual assets in `assets` folder, such as bgm, logo, watermark, etc. don't put them in `.markcut` folder, which is auto generated and will be deleted when `markcut clean` command is run.
|
|
@@ -6,7 +6,7 @@ Complete reference for LLM-driven video generation. The parser uses **remark** (
|
|
|
6
6
|
|
|
7
7
|
A markdown document compiled into a renderable scene tree.
|
|
8
8
|
|
|
9
|
-
- Top heading `#
|
|
9
|
+
- Top heading `# video` marks the document root. (Only `# video` is treated as the main section — `# anything_else` creates a **variant** section for alternate configs, see [Variants](#variants-language--platform--any-override).)
|
|
10
10
|
- Optional YAML frontmatter block `---\n...\n---\n` at the very top
|
|
11
11
|
- Root config line: `width:<n> height:<n> fps:<n> layout:<mode>` (key:value pairs on the line after `# video`)
|
|
12
12
|
- Scenes via `##`/`###`/`####` headings
|
|
@@ -39,7 +39,7 @@ width:1920 height:1080 fps:30 layout:series
|
|
|
39
39
|
~~~css stylesheet
|
|
40
40
|
.bg { background: #000; }
|
|
41
41
|
~~~
|
|
42
|
-
subtitle:captions.vtt
|
|
42
|
+
subtitle:{src:"captions.vtt",type:"Bounce"}
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
Supported keys: `width`, `height`, `fps`, `layout`, `tts`, `stt`, `tti`, `ttv`,
|
|
@@ -49,12 +49,14 @@ Values containing spaces must be quoted with double or single quotes.
|
|
|
49
49
|
|
|
50
50
|
### Subtitle on the config line
|
|
51
51
|
|
|
52
|
+
Set subtitle via `subtitle:` on the root config line:
|
|
53
|
+
|
|
52
54
|
```markdown
|
|
53
55
|
# video
|
|
54
|
-
width:640 height:480 subtitle:captions.vtt
|
|
56
|
+
width:640 height:480 subtitle:{src:"captions.vtt",type:"Bounce",fontSize:48}
|
|
55
57
|
```
|
|
56
58
|
|
|
57
|
-
|
|
59
|
+
See the [Subtitle](#subtitle) section below for all supported fields.
|
|
58
60
|
|
|
59
61
|
## Template Variables
|
|
60
62
|
|
|
@@ -73,16 +75,18 @@ width:1920 height:1080
|
|
|
73
75
|
|
|
74
76
|
> Template variables are NOT resolved in root config keys, jsx, script, style,
|
|
75
77
|
> or other string fields — only in `src`, `prompt`, and `stylesheet`.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
|
|
79
|
+
### Subtitle
|
|
80
|
+
|
|
81
|
+
Subtitles are configured at the root level as a VTT overlay. Set via `subtitle:` on the root config line, or `root.subtitle` in JSON.
|
|
78
82
|
|
|
79
83
|
| Field | Required | Type | Notes |
|
|
80
84
|
|---|---|---|---|
|
|
81
85
|
| `src` | yes | string | VTT path/URL, inline VTT body, or plain text |
|
|
82
|
-
| `type` | opt | string | caption animation: `Bounce`, `Fade`, `Typewriter`, `Colorful`,
|
|
83
|
-
| `fontSize` | opt | number | default 56 |
|
|
86
|
+
| `type` | opt | string | caption animation: `Bounce`, `Fade`, `Typewriter`, `Colorful`, `Glowing`, `Neon`, `Zoom`. Default: plain static caption |
|
|
87
|
+
| `fontSize` | opt | number \| string | default 56 (accepts CSS value like `"2em"` or number) |
|
|
84
88
|
| `fontFamily` | opt | string | font family |
|
|
85
|
-
| `fontStyle` | opt | string | `normal`, `italic`, `bold`,
|
|
89
|
+
| `fontStyle` | opt | string | `normal`, `italic`, `bold`, `bold italic` |
|
|
86
90
|
| `style` | opt | string | inline CSS for the overlay container |
|
|
87
91
|
|
|
88
92
|
> **HTML in cue text**: Cue text supports HTML tags with inline CSS, so you can style individual words:
|
package/src/config.mjs
CHANGED
|
@@ -20,7 +20,7 @@ export const args=(function parseArgs(argv) {
|
|
|
20
20
|
"--tti": "tti",
|
|
21
21
|
"--ttv": "ttv"
|
|
22
22
|
};
|
|
23
|
-
const args = { 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: {} };
|
|
23
|
+
const args = { 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: {} };
|
|
24
24
|
let i = 2;
|
|
25
25
|
if (argv[i]) args.command = argv[i++];
|
|
26
26
|
if (argv[i] && !argv[i].startsWith("--")) args.file = argv[i++];
|
|
@@ -34,6 +34,7 @@ export const args=(function parseArgs(argv) {
|
|
|
34
34
|
else if (flag === "--compile") args.compile = true;
|
|
35
35
|
else if (flag === "--force-new") args.forceNew = true;
|
|
36
36
|
else if (flag === "--verbose") args.verbose = true;
|
|
37
|
+
else if (flag === "--dev") args.dev = true;
|
|
37
38
|
else if (flag === "--label") args.label = true;
|
|
38
39
|
else if (flag === "--edit") args.edit = true;
|
|
39
40
|
else if (flag === "--no-browser") args.noBrowser = true;
|
|
@@ -412,11 +412,17 @@ export async function resolveScripts(
|
|
|
412
412
|
allScriptNodes.push({ node, id });
|
|
413
413
|
});
|
|
414
414
|
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
const totalScripts = allScriptNodes.length;
|
|
416
|
+
let scriptsDone = 0;
|
|
417
|
+
let cacheHits = 0;
|
|
418
|
+
const ttsStart = Date.now();
|
|
419
|
+
|
|
420
|
+
if (totalScripts > 0) {
|
|
421
|
+
console.log(` 🔊 TTS: generating ${totalScripts} script${totalScripts > 1 ? "s" : ""}...`);
|
|
417
422
|
}
|
|
418
423
|
|
|
419
424
|
for (const { node, id } of allScriptNodes) {
|
|
425
|
+
scriptsDone++;
|
|
420
426
|
// TTS CLI from root config only
|
|
421
427
|
let ttsCli = clone.tts ?? options.ttsCli ?? DEFAULT_TTS_CLI;
|
|
422
428
|
|
|
@@ -440,13 +446,15 @@ export async function resolveScripts(
|
|
|
440
446
|
const label = `${speakerLabel}${firstWords(node.script, 8)}`;
|
|
441
447
|
if (cached) {
|
|
442
448
|
generated = cached;
|
|
449
|
+
cacheHits++;
|
|
443
450
|
console.log(` ✓ TTS: ${label} (cached)`);
|
|
444
451
|
} else {
|
|
452
|
+
console.log(` 🔊 TTS (${scriptsDone}/${totalScripts}): ${label}...`);
|
|
445
453
|
generated = generateTTS(node.script, audioPath, ttsCli);
|
|
446
454
|
if (generated) {
|
|
447
455
|
updateCache(cache, `tts:${cacheKey}`, cacheKey, generated);
|
|
448
456
|
cacheDirty = true;
|
|
449
|
-
console.log(` ✓ TTS: ${label}`);
|
|
457
|
+
console.log(` ✓ TTS (${scriptsDone}/${totalScripts}): ${label}`);
|
|
450
458
|
} else {
|
|
451
459
|
console.warn(` ⚠ TTS produced no audio for "${label}". Audio will have no source. Check root.tts config.`);
|
|
452
460
|
}
|
|
@@ -459,10 +467,11 @@ export async function resolveScripts(
|
|
|
459
467
|
}
|
|
460
468
|
|
|
461
469
|
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
462
|
-
if (
|
|
470
|
+
if (totalScripts > 0) {
|
|
463
471
|
try { writeFileSync(join(options.outputDir, ".cache.json"), JSON.stringify(cache, null, 2), "utf-8"); } catch {}
|
|
472
|
+
const ttsElapsed = Math.round((Date.now() - ttsStart) / 1000);
|
|
464
473
|
const unique = new Set(allScriptNodes.filter(s => s.node.src).map(s => s.node.src)).size;
|
|
465
|
-
console.log(` ✅ TTS: ${unique} unique audio
|
|
474
|
+
console.log(` ✅ TTS: ${unique} unique audio${unique > 1 ? "s" : ""} (${scriptsDone} nodes) in ${ttsElapsed}s (${cacheHits} cached)`);
|
|
466
475
|
}
|
|
467
476
|
return clone;
|
|
468
477
|
}
|
|
@@ -713,27 +722,38 @@ export async function resolveGeneratedMedia(
|
|
|
713
722
|
walkDown(clone as any, (node) => {
|
|
714
723
|
if ((node.type !== "image" && node.type !== "video")) return;
|
|
715
724
|
if (!node.prompt || typeof node.prompt !== "string") return;
|
|
716
|
-
// Skip if src is already set to a real path (prompt is just metadata).
|
|
717
|
-
// "auto" is a placeholder meaning "auto-generate via TTI/TTV".
|
|
718
725
|
if (node.src && node.src !== "auto") return;
|
|
719
726
|
const id = node.id ?? `${node.type}-${genNodes.length}`;
|
|
720
727
|
genNodes.push({ node, id, type: node.type as "image" | "video", prompt: node.prompt });
|
|
721
728
|
});
|
|
722
729
|
|
|
730
|
+
const total = genNodes.length;
|
|
731
|
+
let done = 0;
|
|
732
|
+
let cacheHits = 0;
|
|
733
|
+
const startTime = Date.now();
|
|
734
|
+
|
|
735
|
+
function progressLine() {
|
|
736
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
737
|
+
const rate = done / Math.max(elapsed, 0.1);
|
|
738
|
+
const remaining = total - done;
|
|
739
|
+
const eta = rate > 0 ? Math.round(remaining / rate) : "?";
|
|
740
|
+
const etaStr = eta === "?" ? "" : `, ~${eta}s remaining`;
|
|
741
|
+
return ` ${done}/${total} generated (${cacheHits} cached)${etaStr}`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (total > 0) {
|
|
745
|
+
console.log(` 🎨 Generating ${total} media item${total > 1 ? "s" : ""}...`);
|
|
746
|
+
}
|
|
747
|
+
|
|
723
748
|
for (const { node, id, type, prompt } of genNodes) {
|
|
749
|
+
done++;
|
|
724
750
|
const ext = type === "image" ? "png" : "mp4";
|
|
725
751
|
|
|
726
|
-
// Resolve TTI/TTV config: root-level config overrides CLI defaults
|
|
727
752
|
const cli = type === "image"
|
|
728
753
|
? (clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI)
|
|
729
754
|
: (clone.ttv ?? options.ttvCli ?? DEFAULT_TTV_CLI);
|
|
730
755
|
|
|
731
|
-
// Use root.seed for reproducible generation (applied when CLI template has {seed}).
|
|
732
|
-
// seed is part of the cache key so different seeds produce different cached outputs.
|
|
733
756
|
const seed = options.seed;
|
|
734
|
-
|
|
735
|
-
// Content-addressed filename: hash of prompt + CLI + type + seed, so identical
|
|
736
|
-
// prompts with different seeds produce distinct cached media.
|
|
737
757
|
const cacheKey = computeCacheKey({ prompt, cli, type, seed });
|
|
738
758
|
const outputPath = join(options.outputDir, `${cacheKey}.${ext}`);
|
|
739
759
|
|
|
@@ -743,12 +763,13 @@ export async function resolveGeneratedMedia(
|
|
|
743
763
|
|
|
744
764
|
if (cached) {
|
|
745
765
|
node.src = resolvePath(cached);
|
|
746
|
-
|
|
766
|
+
cacheHits++;
|
|
767
|
+
if (done % 5 === 0 || done === total) console.log(progressLine());
|
|
747
768
|
continue;
|
|
748
769
|
}
|
|
749
770
|
|
|
750
771
|
try {
|
|
751
|
-
console.log(` 🔊 ${label}: ${labelText}...`);
|
|
772
|
+
console.log(` 🔊 ${label} (${done}/${total}): ${labelText}...`);
|
|
752
773
|
const ttiCmd = clone.tti ?? options.ttiCli ?? DEFAULT_TTI_CLI;
|
|
753
774
|
const result = type === "image"
|
|
754
775
|
? generateTTI(prompt, outputPath, cli, seed)
|
|
@@ -757,7 +778,8 @@ export async function resolveGeneratedMedia(
|
|
|
757
778
|
node.src = outputPath;
|
|
758
779
|
updateCache(cache, `gen:${cacheKey}`, cacheKey, outputPath);
|
|
759
780
|
cacheDirty = true;
|
|
760
|
-
console.log(` ✓ ${label}: ${labelText}`);
|
|
781
|
+
console.log(` ✓ ${label} (${done}/${total}): ${labelText}`);
|
|
782
|
+
if (done % 3 === 0 || done === total) console.log(progressLine());
|
|
761
783
|
} else {
|
|
762
784
|
const hint = cli.includes("echo")
|
|
763
785
|
? `No ${label} tool installed. The default CLI just echoes a message — set root.${type === "image" ? "tti" : "ttv"} to a real generation command.`
|
|
@@ -772,6 +794,11 @@ export async function resolveGeneratedMedia(
|
|
|
772
794
|
}
|
|
773
795
|
}
|
|
774
796
|
|
|
797
|
+
if (total > 0) {
|
|
798
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
799
|
+
const mediaType = genNodes[0]?.type === "video" ? "TTV" : "TTI";
|
|
800
|
+
console.log(` ✅ ${mediaType} complete: ${done} items in ${elapsed}s (${cacheHits} cached)`);
|
|
801
|
+
}
|
|
775
802
|
if (cacheDirty) writeCacheManifest(options.outputDir, cache);
|
|
776
803
|
return clone;
|
|
777
804
|
}
|
|
@@ -44710,11 +44710,6 @@ function getCurrentStep(leg, currentInSecond) {
|
|
|
44710
44710
|
// src/types/Include.tsx
|
|
44711
44711
|
var React41 = __toESM(require_react(), 1);
|
|
44712
44712
|
var import_jsx_runtime84 = __toESM(require_jsx_runtime(), 1);
|
|
44713
|
-
var ASPECT_DIMS = {
|
|
44714
|
-
"16x9": { width: 1920, height: 1080 },
|
|
44715
|
-
"9x16": { width: 1080, height: 1920 },
|
|
44716
|
-
"1x1": { width: 1080, height: 1080 }
|
|
44717
|
-
};
|
|
44718
44713
|
function isSceneBased(data2) {
|
|
44719
44714
|
if (!data2 || typeof data2 !== "object") return false;
|
|
44720
44715
|
const d2 = data2;
|
|
@@ -44837,8 +44832,7 @@ function IncludeLeaf({ stream: stream2 }) {
|
|
|
44837
44832
|
if (isSceneBased(externalData)) {
|
|
44838
44833
|
const vj = externalData;
|
|
44839
44834
|
const vjFps = vj.meta.fps ?? parentFps;
|
|
44840
|
-
const
|
|
44841
|
-
const dims = ASPECT_DIMS[aspectKey] ?? { width: parentWidth, height: parentHeight };
|
|
44835
|
+
const dims = { width: parentWidth, height: parentHeight };
|
|
44842
44836
|
return /* @__PURE__ */ (0, import_jsx_runtime84.jsxs)(AbsoluteFill, { style: { backgroundColor: "#0a0a0a", width: dims.width, height: dims.height }, children: [
|
|
44843
44837
|
vj.bgm && /* @__PURE__ */ (0, import_jsx_runtime84.jsx)(Audio, { src: vj.bgm.src, volume: vj.bgm.baseVolume }),
|
|
44844
44838
|
vj.scenes.map((scene2) => {
|
package/src/render/cli.mjs
CHANGED
|
@@ -21,12 +21,6 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
21
21
|
const __dirname = dirname(__filename);
|
|
22
22
|
const ROOT = resolve(__dirname, "../..");
|
|
23
23
|
|
|
24
|
-
const ASPECTS = {
|
|
25
|
-
"16x9": { width: 1920, height: 1080 },
|
|
26
|
-
"9x16": { width: 1080, height: 1920 },
|
|
27
|
-
"1x1": { width: 1080, height: 1080 },
|
|
28
|
-
};
|
|
29
|
-
|
|
30
24
|
/**
|
|
31
25
|
* How many "Rendered X/Y" lines to skip before printing one.
|
|
32
26
|
* E.g. 50 means print every 50th frame — for 1860 frames that's ~37 lines.
|
|
@@ -52,6 +46,7 @@ markcut CLI — Markdown/JSON → video pipeline
|
|
|
52
46
|
npx @lalalic/markcut <command> [options]
|
|
53
47
|
|
|
54
48
|
global options:
|
|
49
|
+
--dev Use development build (unminified React error messages)
|
|
55
50
|
--show-clis Print all default CLI templates and exit
|
|
56
51
|
|
|
57
52
|
--tti <template> Override default TTI CLI template
|
|
@@ -92,7 +87,7 @@ Commands:
|
|
|
92
87
|
|
|
93
88
|
|
|
94
89
|
/**
|
|
95
|
-
* Render
|
|
90
|
+
* Render a stream tree to an MP4 video with compact progress output.
|
|
96
91
|
*
|
|
97
92
|
* In compact mode (default), "Rendered X/Y" lines are shown only every
|
|
98
93
|
* PROGRESS_INTERVAL frames and at the final frame, drastically reducing
|
|
@@ -100,28 +95,49 @@ Commands:
|
|
|
100
95
|
*
|
|
101
96
|
* Use --verbose to see every frame line (original behavior).
|
|
102
97
|
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve a potentially relative source path against baseDir, skipping
|
|
101
|
+
* absolute paths, URLs, and data URIs.
|
|
102
|
+
*/
|
|
103
|
+
function resolveAssetPath(src, baseDir) {
|
|
104
|
+
if (!src || typeof src !== "string") return null;
|
|
105
|
+
// Already absolute on disk
|
|
106
|
+
if (src.startsWith("/")) return src;
|
|
107
|
+
// URL or data URI — can't stage locally
|
|
108
|
+
if (/^(https?:|data:|blob:)/.test(src)) return null;
|
|
109
|
+
// Relative path: resolve against the input file's directory
|
|
110
|
+
const abs = resolve(baseDir, src);
|
|
111
|
+
if (existsSync(abs)) return abs;
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
103
115
|
/**
|
|
104
|
-
* Stage local
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* The preview server handles these paths via multi-root
|
|
108
|
-
* CLI needs them staged into the one public dir Remotion
|
|
116
|
+
* Stage all local src files (absolute and relative) into
|
|
117
|
+
* ROOT/public/.render-assets/ so `npx remotion render` (publicDir = ROOT/public)
|
|
118
|
+
* can serve them. Relative paths are resolved against `baseDir` (the input
|
|
119
|
+
* file's directory). The preview server handles these paths via multi-root
|
|
120
|
+
* serving; the render CLI needs them staged into the one public dir Remotion
|
|
121
|
+
* knows about.
|
|
109
122
|
*/
|
|
110
|
-
function stageLocalAssets(tree) {
|
|
123
|
+
function stageLocalAssets(tree, baseDir) {
|
|
111
124
|
const assetsDir = join(ROOT, "public", ".render-assets");
|
|
112
125
|
const seen = new Map();
|
|
113
126
|
const walk = (node) => {
|
|
114
127
|
if (!node || typeof node !== "object") return;
|
|
115
|
-
if (typeof node.src === "string"
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
if (typeof node.src === "string") {
|
|
129
|
+
const absPath = resolveAssetPath(node.src, baseDir);
|
|
130
|
+
if (absPath) {
|
|
131
|
+
let staged = seen.get(absPath);
|
|
132
|
+
if (!staged) {
|
|
133
|
+
const name = createHash("md5").update(absPath).digest("hex").slice(0, 12) + extname(absPath);
|
|
134
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
135
|
+
copyFileSync(absPath, join(assetsDir, name));
|
|
136
|
+
staged = `.render-assets/${name}`;
|
|
137
|
+
seen.set(absPath, staged);
|
|
138
|
+
}
|
|
139
|
+
node.src = staged;
|
|
123
140
|
}
|
|
124
|
-
node.src = staged;
|
|
125
141
|
}
|
|
126
142
|
for (const value of Object.values(node)) {
|
|
127
143
|
if (value && typeof value === "object") walk(value);
|
|
@@ -131,21 +147,24 @@ function stageLocalAssets(tree) {
|
|
|
131
147
|
return tree;
|
|
132
148
|
}
|
|
133
149
|
|
|
134
|
-
function renderOne(streamTree,
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const adapted = stageLocalAssets(JSON.parse(JSON.stringify({ ...streamTree, width: dims.width, height: dims.height })));
|
|
139
|
-
const tmpProps = join(ROOT, ".tmp", `render-${aspect}.json`);
|
|
150
|
+
function renderOne(streamTree, outputPath, verbose, baseDir) {
|
|
151
|
+
const adapted = stageLocalAssets(JSON.parse(JSON.stringify(streamTree)), baseDir);
|
|
152
|
+
const tmpProps = join(ROOT, ".tmp", "render-stream.json");
|
|
140
153
|
mkdirSync(dirname(tmpProps), { recursive: true });
|
|
141
154
|
writeFileSync(tmpProps, JSON.stringify({ root: adapted }));
|
|
142
155
|
|
|
143
156
|
mkdirSync(dirname(outputPath), { recursive: true });
|
|
144
157
|
|
|
145
|
-
|
|
158
|
+
console.log(`\n▶ Rendering → ${outputPath}`);
|
|
146
159
|
|
|
147
160
|
return new Promise((resolvePromise, reject) => {
|
|
148
|
-
|
|
161
|
+
// Pass NODE_ENV=development to get unminified React error messages (--dev flag)
|
|
162
|
+
const spawnOpts = { cwd: ROOT, stdio: ["ignore", "inherit", "pipe"] };
|
|
163
|
+
// args is imported from config.mjs — check dev flag there
|
|
164
|
+
if (args.dev) {
|
|
165
|
+
spawnOpts.env = { ...process.env, NODE_ENV: "development" };
|
|
166
|
+
}
|
|
167
|
+
const proc = spawn("npx", ["remotion", "render", "Root", outputPath, "--props", tmpProps, "--config", "remotion.config.ts"], spawnOpts);
|
|
149
168
|
|
|
150
169
|
let lastLoggedFrame = 0;
|
|
151
170
|
let totalFrames = 0;
|
|
@@ -401,7 +420,8 @@ edit=${DEFAULT_EDIT_CLI}`);
|
|
|
401
420
|
}
|
|
402
421
|
|
|
403
422
|
const output = args.output ? resolve(args.output) : join(ROOT, "out", "video.mp4");
|
|
404
|
-
|
|
423
|
+
const fileDir = dirname(resolve(args.file));
|
|
424
|
+
await renderOne(streamTree, output, args.verbose, fileDir);
|
|
405
425
|
|
|
406
426
|
console.log("\n✅ Render complete.");
|
|
407
427
|
process.exit(0);
|
|
@@ -441,12 +461,27 @@ function hasScript(root) {
|
|
|
441
461
|
emitInfo(`File: ${filePath}`);
|
|
442
462
|
emitInfo(`Format: ${isMarkdown ? "Markdown" : "JSON"}`);
|
|
443
463
|
|
|
444
|
-
const { compileDescriptiveRoot, parseMarkdownDescriptive, parseImportsBlock } = await import("../player/pipeline.mjs");
|
|
464
|
+
const { compileDescriptiveRoot, parseMarkdownDescriptive, parseMarkdownVariants, parseImportsBlock } = await import("../player/pipeline.mjs");
|
|
445
465
|
|
|
446
466
|
try {
|
|
447
467
|
let descriptive, needsTti, needsTtv;
|
|
448
468
|
if (isMarkdown) {
|
|
449
469
|
descriptive = parseMarkdownDescriptive(raw);
|
|
470
|
+
|
|
471
|
+
// Check if content accidentally went into a variant section
|
|
472
|
+
// (happens when root heading is e.g. "# My Movie" instead of "# video")
|
|
473
|
+
const hasChildren = (descriptive.children?.length ?? 0) > 0;
|
|
474
|
+
if (!hasChildren) {
|
|
475
|
+
const variantResult = parseMarkdownVariants(raw);
|
|
476
|
+
const variantNames = [...variantResult.variants.keys()];
|
|
477
|
+
const variantChildren = variantNames.filter(n => (variantResult.variants.get(n)?.children?.length ?? 0) > 0);
|
|
478
|
+
if (variantChildren.length > 0) {
|
|
479
|
+
warnings.push(
|
|
480
|
+
`No scenes found in the main section (use '# video' as the root heading). ` +
|
|
481
|
+
`Content appears in variant section(s): ${variantChildren.join(", ")}`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
450
485
|
} else {
|
|
451
486
|
const parsed = JSON.parse(raw);
|
|
452
487
|
const root = parsed.root ?? parsed;
|
package/src/types/Include.tsx
CHANGED
|
@@ -5,15 +5,8 @@ import { cssJS, toClassName, getDurationInSeconds, type DurationStream } from ".
|
|
|
5
5
|
import type { Include as IncludeStream, Root } from "../schema/index";
|
|
6
6
|
import { FolderLeaf } from "./Folder";
|
|
7
7
|
|
|
8
|
-
// Aspect dimensions for scene-based video content
|
|
9
|
-
const ASPECT_DIMS: Record<string, { width: number; height: number }> = {
|
|
10
|
-
"16x9": { width: 1920, height: 1080 },
|
|
11
|
-
"9x16": { width: 1080, height: 1920 },
|
|
12
|
-
"1x1": { width: 1080, height: 1080 },
|
|
13
|
-
};
|
|
14
|
-
|
|
15
8
|
interface SceneBasedVideo {
|
|
16
|
-
meta: { title: string; fps: number
|
|
9
|
+
meta: { title: string; fps: number };
|
|
17
10
|
voiceover?: { tts: string; voice: string };
|
|
18
11
|
bgm?: { src: string; baseVolume: number };
|
|
19
12
|
scenes: Array<{
|
|
@@ -207,9 +200,8 @@ export function IncludeLeaf({ stream }: { stream: IncludeStream }) {
|
|
|
207
200
|
// ── Scene-based video.json ──────────────────────────────────
|
|
208
201
|
const vj = externalData;
|
|
209
202
|
const vjFps = vj.meta.fps ?? parentFps;
|
|
210
|
-
// Use
|
|
211
|
-
const
|
|
212
|
-
const dims = ASPECT_DIMS[aspectKey] ?? { width: parentWidth, height: parentHeight };
|
|
203
|
+
// Use parent composition dimensions (dimensions come from the stream file)
|
|
204
|
+
const dims = { width: parentWidth, height: parentHeight };
|
|
213
205
|
|
|
214
206
|
return (
|
|
215
207
|
<AbsoluteFill style={{ backgroundColor: "#0a0a0a", width: dims.width, height: dims.height }}>
|
package/src/types/Subtitle.tsx
CHANGED
|
@@ -288,9 +288,9 @@ export function SubtitleOverlay({ subtitle }: { subtitle: SubtitleOverlayConfig
|
|
|
288
288
|
};
|
|
289
289
|
}, [subtitle.src]);
|
|
290
290
|
|
|
291
|
-
if (!cues) return null
|
|
291
|
+
if (!cues) return null;@
|
|
292
292
|
|
|
293
|
-
const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {}
|
|
293
|
+
const boxCss = subtitle.style ? (cssJS(subtitle.style) as React.CSSProperties) : {};@
|
|
294
294
|
|
|
295
295
|
// Group consecutive cues with same plain text (word-level highlighting pattern)
|
|
296
296
|
// so the caption component stays mounted across word transitions — no flash.
|
package/tests/render.test.ts
CHANGED
|
@@ -473,11 +473,11 @@ describe("Audio STT Verification", () => {
|
|
|
473
473
|
});
|
|
474
474
|
|
|
475
475
|
// ───────────────────────────────────────────────────────────────────────────
|
|
476
|
-
// 11.
|
|
476
|
+
// 11. Dimensions from Stream File
|
|
477
477
|
// ───────────────────────────────────────────────────────────────────────────
|
|
478
478
|
|
|
479
|
-
describe("
|
|
480
|
-
it("
|
|
479
|
+
describe("Dimensions from Stream File", () => {
|
|
480
|
+
it("respects width/height defined in the stream tree root", async () => {
|
|
481
481
|
const fixture = {
|
|
482
482
|
root: {
|
|
483
483
|
id: "root",
|
|
@@ -490,7 +490,7 @@ describe("Multiple Aspect Ratios", () => {
|
|
|
490
490
|
{
|
|
491
491
|
id: "bg",
|
|
492
492
|
type: "image",
|
|
493
|
-
src: "https://picsum.photos/seed/
|
|
493
|
+
src: "https://picsum.photos/seed/dim-test/1920/1080",
|
|
494
494
|
fit: "cover",
|
|
495
495
|
start: 0,
|
|
496
496
|
end: 2,
|
|
@@ -498,7 +498,7 @@ describe("Multiple Aspect Ratios", () => {
|
|
|
498
498
|
{
|
|
499
499
|
id: "title",
|
|
500
500
|
type: "image",
|
|
501
|
-
src: "https://picsum.photos/seed/
|
|
501
|
+
src: "https://picsum.photos/seed/dim-title/1920/1080",
|
|
502
502
|
fit: "cover",
|
|
503
503
|
start: 0.3,
|
|
504
504
|
end: 1.7,
|
|
@@ -508,18 +508,16 @@ describe("Multiple Aspect Ratios", () => {
|
|
|
508
508
|
};
|
|
509
509
|
|
|
510
510
|
const { writeFileSync } = await import("node:fs");
|
|
511
|
-
const tmpFixture = outPath("
|
|
511
|
+
const tmpFixture = outPath("_dims.json");
|
|
512
512
|
writeFileSync(tmpFixture, JSON.stringify(fixture));
|
|
513
513
|
|
|
514
|
-
//
|
|
515
|
-
// Note: Aspect adaptation happens in the CLI, so we test with the default
|
|
514
|
+
// Dimensions come from the stream file root, not from a CLI --aspect flag
|
|
516
515
|
const output = renderFixture(tmpFixture, {
|
|
517
|
-
outputName: "
|
|
516
|
+
outputName: "dims-default.mp4",
|
|
518
517
|
timeout: RENDER_TIMEOUT,
|
|
519
518
|
});
|
|
520
519
|
|
|
521
520
|
const info = getVideoInfo(output);
|
|
522
|
-
// Default aspect is whatever the fixture says (1920x1080)
|
|
523
521
|
expect(info.width).toBe(1920);
|
|
524
522
|
expect(info.height).toBe(1080);
|
|
525
523
|
|