@lalalic/markcut 2.3.0 → 2.4.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/AGENTS.md +5 -5
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/markcut/SKILL.md +5 -1
- package/skills/markcut/docs/markdown-descriptive.md +16 -7
- package/src/config.mjs +2 -1
- package/src/descriptive/compiler.ts +9 -5
- package/src/descriptive/markdown.ts +3 -1
- package/src/descriptive/resolve.ts +45 -17
- package/src/player/browser.tsx +32 -13
- package/src/player/bundle/player.js +93 -35
- package/src/player/components/SceneThumbnails.tsx +5 -2
- package/src/player/pipeline.mjs +12 -8
- package/src/player/pipeline.ts +4 -0
- package/src/player/server-shared.mjs +15 -6
- package/src/render/cli.mjs +67 -32
- package/src/schema/index.ts +0 -2
- package/src/types/Audio.tsx +1 -2
- package/src/types/Include.tsx +3 -11
- package/src/types/Subtitle.tsx +107 -12
- package/src/types/Video.tsx +1 -1
- package/tests/render.test.ts +8 -10
|
@@ -30520,7 +30520,7 @@ function VideoLeaf({ stream: stream2 }) {
|
|
|
30520
30520
|
const startFrom = stream2.startFrom ?? 0;
|
|
30521
30521
|
const endAt = stream2.endAt ?? totalDur;
|
|
30522
30522
|
const volume = stream2.volume ?? 1;
|
|
30523
|
-
const playbackRate =
|
|
30523
|
+
const playbackRate = Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
|
|
30524
30524
|
const streamStyle = cssJS(stream2.style);
|
|
30525
30525
|
const hasAnimation = "animation" in streamStyle;
|
|
30526
30526
|
return /* @__PURE__ */ (0, import_jsx_runtime78.jsx)(
|
|
@@ -30580,7 +30580,7 @@ function AudioLeaf({ stream: stream2 }) {
|
|
|
30580
30580
|
const startFrom = stream2.startFrom ?? 0;
|
|
30581
30581
|
const endAt = stream2.endAt ?? totalDur;
|
|
30582
30582
|
const volume = stream2.volume ?? 1;
|
|
30583
|
-
const playbackRate =
|
|
30583
|
+
const playbackRate = toPlaybackRate((endAt - startFrom) / (end - start));
|
|
30584
30584
|
return /* @__PURE__ */ (0, import_jsx_runtime79.jsx)(
|
|
30585
30585
|
Sequence,
|
|
30586
30586
|
{
|
|
@@ -30597,7 +30597,6 @@ function AudioLeaf({ stream: stream2 }) {
|
|
|
30597
30597
|
endAt: Math.floor(startFrom * fps) + Math.floor((endAt - startFrom) * fps / playbackRate),
|
|
30598
30598
|
muted: volume === 0 || !!ctx?.foreground,
|
|
30599
30599
|
volume,
|
|
30600
|
-
loop: (stream2.loop ?? 1) > 1,
|
|
30601
30600
|
playbackRate,
|
|
30602
30601
|
showInTimeline: false
|
|
30603
30602
|
}
|
|
@@ -44711,11 +44710,6 @@ function getCurrentStep(leg, currentInSecond) {
|
|
|
44711
44710
|
// src/types/Include.tsx
|
|
44712
44711
|
var React41 = __toESM(require_react(), 1);
|
|
44713
44712
|
var import_jsx_runtime84 = __toESM(require_jsx_runtime(), 1);
|
|
44714
|
-
var ASPECT_DIMS = {
|
|
44715
|
-
"16x9": { width: 1920, height: 1080 },
|
|
44716
|
-
"9x16": { width: 1080, height: 1920 },
|
|
44717
|
-
"1x1": { width: 1080, height: 1080 }
|
|
44718
|
-
};
|
|
44719
44713
|
function isSceneBased(data2) {
|
|
44720
44714
|
if (!data2 || typeof data2 !== "object") return false;
|
|
44721
44715
|
const d2 = data2;
|
|
@@ -44838,8 +44832,7 @@ function IncludeLeaf({ stream: stream2 }) {
|
|
|
44838
44832
|
if (isSceneBased(externalData)) {
|
|
44839
44833
|
const vj = externalData;
|
|
44840
44834
|
const vjFps = vj.meta.fps ?? parentFps;
|
|
44841
|
-
const
|
|
44842
|
-
const dims = ASPECT_DIMS[aspectKey] ?? { width: parentWidth, height: parentHeight };
|
|
44835
|
+
const dims = { width: parentWidth, height: parentHeight };
|
|
44843
44836
|
return /* @__PURE__ */ (0, import_jsx_runtime84.jsxs)(AbsoluteFill, { style: { backgroundColor: "#0a0a0a", width: dims.width, height: dims.height }, children: [
|
|
44844
44837
|
vj.bgm && /* @__PURE__ */ (0, import_jsx_runtime84.jsx)(Audio, { src: vj.bgm.src, volume: vj.bgm.baseVolume }),
|
|
44845
44838
|
vj.scenes.map((scene2) => {
|
|
@@ -45891,15 +45884,41 @@ function supportHtml(text) {
|
|
|
45891
45884
|
});
|
|
45892
45885
|
return el;
|
|
45893
45886
|
}
|
|
45887
|
+
function stripHtml(text) {
|
|
45888
|
+
return text.replace(/<[^>]*>/g, "").trim();
|
|
45889
|
+
}
|
|
45890
|
+
function groupConsecutiveCues(cues) {
|
|
45891
|
+
const groups = [];
|
|
45892
|
+
let current2 = [];
|
|
45893
|
+
for (const cue of cues) {
|
|
45894
|
+
const plain = stripHtml(cue.text);
|
|
45895
|
+
if (current2.length === 0) {
|
|
45896
|
+
current2.push(cue);
|
|
45897
|
+
} else {
|
|
45898
|
+
const prevPlain = stripHtml(current2[current2.length - 1].text);
|
|
45899
|
+
if (plain === prevPlain) {
|
|
45900
|
+
current2.push(cue);
|
|
45901
|
+
} else {
|
|
45902
|
+
groups.push(current2);
|
|
45903
|
+
current2 = [cue];
|
|
45904
|
+
}
|
|
45905
|
+
}
|
|
45906
|
+
}
|
|
45907
|
+
if (current2.length > 0) groups.push(current2);
|
|
45908
|
+
return groups;
|
|
45909
|
+
}
|
|
45894
45910
|
function CueFrame({
|
|
45895
45911
|
cue,
|
|
45896
45912
|
fps,
|
|
45897
45913
|
CaptionComponent,
|
|
45898
|
-
subtitle
|
|
45914
|
+
subtitle,
|
|
45915
|
+
cueGroup
|
|
45899
45916
|
}) {
|
|
45900
|
-
const
|
|
45901
|
-
const
|
|
45902
|
-
const
|
|
45917
|
+
const group = cueGroup ?? [cue];
|
|
45918
|
+
const startFrom = group[0].startFrom;
|
|
45919
|
+
const endAt = group[group.length - 1].endAt;
|
|
45920
|
+
const durationInFrames = Math.max(1, Math.floor((endAt - startFrom) * fps));
|
|
45921
|
+
const from = Math.floor(startFrom * fps);
|
|
45903
45922
|
const textStyle = React46.useMemo(
|
|
45904
45923
|
() => ({
|
|
45905
45924
|
...DEFAULT_TEXT_STYLE,
|
|
@@ -45909,7 +45928,33 @@ function CueFrame({
|
|
|
45909
45928
|
}),
|
|
45910
45929
|
[subtitle.fontSize, subtitle.fontFamily, subtitle.fontStyle]
|
|
45911
45930
|
);
|
|
45912
|
-
return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(Sequence, { layout: "none", durationInFrames, from, children: /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(
|
|
45931
|
+
return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(Sequence, { layout: "none", durationInFrames, from, children: /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(
|
|
45932
|
+
GroupedCueInner,
|
|
45933
|
+
{
|
|
45934
|
+
group,
|
|
45935
|
+
fps,
|
|
45936
|
+
CaptionComponent,
|
|
45937
|
+
textStyle
|
|
45938
|
+
}
|
|
45939
|
+
) });
|
|
45940
|
+
}
|
|
45941
|
+
function GroupedCueInner({
|
|
45942
|
+
group,
|
|
45943
|
+
fps,
|
|
45944
|
+
CaptionComponent,
|
|
45945
|
+
textStyle
|
|
45946
|
+
}) {
|
|
45947
|
+
const frame = useCurrentFrame();
|
|
45948
|
+
const currentTime = frame / fps;
|
|
45949
|
+
let activeCue = group[group.length - 1];
|
|
45950
|
+
for (const c3 of group) {
|
|
45951
|
+
if (currentTime >= c3.startFrom && currentTime < c3.endAt) {
|
|
45952
|
+
activeCue = c3;
|
|
45953
|
+
break;
|
|
45954
|
+
}
|
|
45955
|
+
}
|
|
45956
|
+
const captionText = React46.useMemo(() => supportHtml(activeCue.text), [activeCue.text]);
|
|
45957
|
+
return /* @__PURE__ */ (0, import_jsx_runtime88.jsx)(CaptionComponent, { text: captionText, style: textStyle });
|
|
45913
45958
|
}
|
|
45914
45959
|
function SubtitleOverlay({ subtitle }) {
|
|
45915
45960
|
const { fps } = useVideoConfig();
|
|
@@ -45955,15 +46000,17 @@ function SubtitleOverlay({ subtitle }) {
|
|
|
45955
46000
|
}, [subtitle.src]);
|
|
45956
46001
|
if (!cues) return null;
|
|
45957
46002
|
const boxCss = subtitle.style ? cssJS(subtitle.style) : {};
|
|
45958
|
-
|
|
46003
|
+
const cueGroups = React46.useMemo(() => groupConsecutiveCues(cues), [cues]);
|
|
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)(
|
|
45959
46005
|
CueFrame,
|
|
45960
46006
|
{
|
|
45961
|
-
cue,
|
|
46007
|
+
cue: group[0],
|
|
46008
|
+
cueGroup: group,
|
|
45962
46009
|
fps,
|
|
45963
46010
|
CaptionComponent,
|
|
45964
46011
|
subtitle
|
|
45965
46012
|
},
|
|
45966
|
-
|
|
46013
|
+
`g${gi}-${group[0].startFrom}-${group[group.length - 1].endAt}`
|
|
45967
46014
|
)) });
|
|
45968
46015
|
}
|
|
45969
46016
|
|
|
@@ -59804,15 +59851,13 @@ var video = base.extend({
|
|
|
59804
59851
|
volume: external_exports.number().min(0).max(1).default(1),
|
|
59805
59852
|
playbackRate: external_exports.number().optional(),
|
|
59806
59853
|
width: external_exports.number().default(1080),
|
|
59807
|
-
height: external_exports.number().default(1920)
|
|
59808
|
-
loop: external_exports.number().int().min(1).optional().describe(">1 = loop count")
|
|
59854
|
+
height: external_exports.number().default(1920)
|
|
59809
59855
|
});
|
|
59810
59856
|
var audio = base.extend({
|
|
59811
59857
|
type: external_exports.literal("audio").default("audio"),
|
|
59812
59858
|
src: external_exports.string().optional(),
|
|
59813
59859
|
volume: external_exports.number().min(0).max(1).default(1),
|
|
59814
59860
|
foreground: external_exports.boolean().optional().describe("ducks parent video audio while playing"),
|
|
59815
|
-
loop: external_exports.number().int().min(1).optional().describe(">1 = loop count"),
|
|
59816
59861
|
speaker: external_exports.string().optional().describe("speaker name for multi-turn dialogue; set by resolveDialogue")
|
|
59817
59862
|
});
|
|
59818
59863
|
var image = base.extend({
|
|
@@ -60361,8 +60406,8 @@ function SceneThumbnails({ currentTime, onSeek }) {
|
|
|
60361
60406
|
}, []);
|
|
60362
60407
|
if (scenes.length === 0) return null;
|
|
60363
60408
|
let activeIdx = -1;
|
|
60364
|
-
for (let i3 =
|
|
60365
|
-
if (currentTime >= scenes[i3].start
|
|
60409
|
+
for (let i3 = scenes.length - 1; i3 >= 0; i3--) {
|
|
60410
|
+
if (currentTime >= scenes[i3].start) {
|
|
60366
60411
|
activeIdx = i3;
|
|
60367
60412
|
break;
|
|
60368
60413
|
}
|
|
@@ -60498,9 +60543,9 @@ function PlayerApp() {
|
|
|
60498
60543
|
const scenes = window.__scenes;
|
|
60499
60544
|
if (!scenes) return;
|
|
60500
60545
|
let found = "";
|
|
60501
|
-
for (
|
|
60502
|
-
if (currentTime >=
|
|
60503
|
-
found =
|
|
60546
|
+
for (let i3 = scenes.length - 1; i3 >= 0; i3--) {
|
|
60547
|
+
if (currentTime >= scenes[i3].start) {
|
|
60548
|
+
found = scenes[i3].name || "";
|
|
60504
60549
|
break;
|
|
60505
60550
|
}
|
|
60506
60551
|
}
|
|
@@ -60555,10 +60600,20 @@ function PlayerApp() {
|
|
|
60555
60600
|
const handleFrameUpdate = React55.useCallback((frame) => {
|
|
60556
60601
|
currentFrameRef.current = frame;
|
|
60557
60602
|
setCurrentTime((prev) => {
|
|
60558
|
-
const newTime = frame /
|
|
60559
|
-
return Math.abs(newTime - prev) > 0.
|
|
60603
|
+
const newTime = frame / fps;
|
|
60604
|
+
return Math.abs(newTime - prev) > 0.1 ? newTime : prev;
|
|
60560
60605
|
});
|
|
60561
|
-
}, [
|
|
60606
|
+
}, [fps]);
|
|
60607
|
+
React55.useEffect(() => {
|
|
60608
|
+
const p2 = playerRef.current;
|
|
60609
|
+
if (!p2 || typeof p2.addEventListener !== "function") return;
|
|
60610
|
+
const listener = (e) => {
|
|
60611
|
+
const frame = e?.detail?.frame;
|
|
60612
|
+
if (typeof frame === "number") handleFrameUpdate(frame);
|
|
60613
|
+
};
|
|
60614
|
+
p2.addEventListener("frameupdate", listener);
|
|
60615
|
+
return () => p2.removeEventListener("frameupdate", listener);
|
|
60616
|
+
}, [ready, data2, handleFrameUpdate]);
|
|
60562
60617
|
React55.useEffect(() => {
|
|
60563
60618
|
if (!ready || !playerRef.current) return;
|
|
60564
60619
|
const FWD_SECONDS = 5;
|
|
@@ -60567,13 +60622,16 @@ function PlayerApp() {
|
|
|
60567
60622
|
function seekRelative(deltaSec) {
|
|
60568
60623
|
const p2 = playerRef.current;
|
|
60569
60624
|
if (!p2) return;
|
|
60570
|
-
const frame = p2.getCurrentFrame() + Math.round(deltaSec * fps);
|
|
60571
|
-
p2.seekTo(
|
|
60625
|
+
const frame = Math.max(0, p2.getCurrentFrame() + Math.round(deltaSec * fps));
|
|
60626
|
+
p2.seekTo(frame);
|
|
60627
|
+
setCurrentTime(frame / fps);
|
|
60572
60628
|
}
|
|
60573
60629
|
function seekPercent(pct) {
|
|
60574
60630
|
const p2 = playerRef.current;
|
|
60575
60631
|
if (!p2) return;
|
|
60576
|
-
|
|
60632
|
+
const frame = Math.round(pct * durationInFrames);
|
|
60633
|
+
p2.seekTo(frame);
|
|
60634
|
+
setCurrentTime(frame / fps);
|
|
60577
60635
|
}
|
|
60578
60636
|
function showHelp() {
|
|
60579
60637
|
console.log(`%c\u{1F3AC} MarkCut Player Shortcuts
|
|
@@ -60746,8 +60804,7 @@ function PlayerApp() {
|
|
|
60746
60804
|
allowFullscreen: true,
|
|
60747
60805
|
clickToPlay: false,
|
|
60748
60806
|
doubleClickToFullscreen: true,
|
|
60749
|
-
autoPlay
|
|
60750
|
-
onFrameUpdate: handleFrameUpdate
|
|
60807
|
+
autoPlay
|
|
60751
60808
|
}
|
|
60752
60809
|
) }),
|
|
60753
60810
|
/* @__PURE__ */ (0, import_jsx_runtime95.jsx)(
|
|
@@ -60756,8 +60813,9 @@ function PlayerApp() {
|
|
|
60756
60813
|
currentTime,
|
|
60757
60814
|
onSeek: (t) => {
|
|
60758
60815
|
if (playerRef.current) {
|
|
60759
|
-
const frame = Math.round(t *
|
|
60816
|
+
const frame = Math.round(t * fps);
|
|
60760
60817
|
playerRef.current.seekTo(frame);
|
|
60818
|
+
setCurrentTime(t);
|
|
60761
60819
|
}
|
|
60762
60820
|
}
|
|
60763
60821
|
}
|
|
@@ -35,9 +35,12 @@ export function SceneThumbnails({ currentTime, onSeek }: SceneThumbnailsProps) {
|
|
|
35
35
|
|
|
36
36
|
if (scenes.length === 0) return null;
|
|
37
37
|
|
|
38
|
+
// With transition overlaps, scenes can overlap in time (scene[i].end > scene[i+1].start).
|
|
39
|
+
// Iterate backward so the latest scene whose start <= currentTime wins — i.e., as
|
|
40
|
+
// soon as the next scene starts (during a transition), it becomes the active one.
|
|
38
41
|
let activeIdx = -1;
|
|
39
|
-
for (let i =
|
|
40
|
-
if (currentTime >= scenes[i].start
|
|
42
|
+
for (let i = scenes.length - 1; i >= 0; i--) {
|
|
43
|
+
if (currentTime >= scenes[i].start) {
|
|
41
44
|
activeIdx = i;
|
|
42
45
|
break;
|
|
43
46
|
}
|
package/src/player/pipeline.mjs
CHANGED
|
@@ -259,7 +259,6 @@ function compileLeaf(node2, ctx, parentKind) {
|
|
|
259
259
|
src: node2.src,
|
|
260
260
|
volume: node2.volume ?? 1,
|
|
261
261
|
foreground: node2.foreground,
|
|
262
|
-
loop: node2.loop,
|
|
263
262
|
speaker: node2.speaker
|
|
264
263
|
};
|
|
265
264
|
return { stream, duration: end };
|
|
@@ -709,8 +708,11 @@ function resolveVariantOverrides(root, variantChain) {
|
|
|
709
708
|
}
|
|
710
709
|
function compileDescriptiveRoot(input, options = {}) {
|
|
711
710
|
const root = typeof input === "string" ? JSON.parse(input) : input;
|
|
712
|
-
const resolved = resolveTransition(
|
|
713
|
-
|
|
711
|
+
const resolved = resolveTransition(
|
|
712
|
+
root.layout === "transitionSeries" ? root.transition ?? "fade" : root.transition,
|
|
713
|
+
root.transitionTime
|
|
714
|
+
);
|
|
715
|
+
const rootKind = root.layout === "parallel" ? "parallel" : root.layout === "transitionSeries" ? "transitionSeries" : "series";
|
|
714
716
|
const googleMapsApiKey = options.googleMapsApiKey ?? "";
|
|
715
717
|
const ctx = {
|
|
716
718
|
defaults: {
|
|
@@ -736,8 +738,8 @@ function compileDescriptiveRoot(input, options = {}) {
|
|
|
736
738
|
stylesheet: input.stylesheet,
|
|
737
739
|
subtitle: input.subtitle,
|
|
738
740
|
isSeries: rootKind !== "parallel",
|
|
739
|
-
transition:
|
|
740
|
-
transitionTime:
|
|
741
|
+
transition: root.transition || root.layout === "transitionSeries" ? resolved.name : void 0,
|
|
742
|
+
transitionTime: root.transition || root.layout === "transitionSeries" ? resolved.time : 0.5,
|
|
741
743
|
children: children.map((c) => c.stream),
|
|
742
744
|
durationInSeconds: duration
|
|
743
745
|
};
|
|
@@ -10131,7 +10133,6 @@ function preserveVariantAttrs(node2, attrs) {
|
|
|
10131
10133
|
"duration",
|
|
10132
10134
|
"startFrom",
|
|
10133
10135
|
"endAt",
|
|
10134
|
-
"loop",
|
|
10135
10136
|
"width",
|
|
10136
10137
|
"height",
|
|
10137
10138
|
"fit",
|
|
@@ -10264,7 +10265,6 @@ function parseNodeLine(content3, lineNum) {
|
|
|
10264
10265
|
endAt: attrs.endAt,
|
|
10265
10266
|
volume: attrs.volume,
|
|
10266
10267
|
foreground: attrs.foreground,
|
|
10267
|
-
loop: attrs.loop,
|
|
10268
10268
|
instruction: attrs.instruction,
|
|
10269
10269
|
script: attrs.script,
|
|
10270
10270
|
visible: attrs.visible,
|
|
@@ -10594,6 +10594,9 @@ function applyRootAttrs(root, attrs) {
|
|
|
10594
10594
|
case "metadata":
|
|
10595
10595
|
root.metadata = String(v);
|
|
10596
10596
|
break;
|
|
10597
|
+
case "seed":
|
|
10598
|
+
root.seed = Number(v);
|
|
10599
|
+
break;
|
|
10597
10600
|
case "stylesheet":
|
|
10598
10601
|
root.stylesheet = String(v);
|
|
10599
10602
|
break;
|
|
@@ -11148,7 +11151,7 @@ async function resolveAll2(root, options = {}) {
|
|
|
11148
11151
|
variant: options.variants?.[0] ?? "video"
|
|
11149
11152
|
});
|
|
11150
11153
|
result = await resolveMediaSrcs(result, { baseDir: options.baseDir });
|
|
11151
|
-
const generationSeed = result.seed ?? options.seed;
|
|
11154
|
+
const generationSeed = result.seed ?? options.seed ?? Math.floor(Math.random() * 2147483647);
|
|
11152
11155
|
if (options.mediaOutputDir) {
|
|
11153
11156
|
result = await resolveGeneratedMedia(result, {
|
|
11154
11157
|
outputDir: options.mediaOutputDir,
|
|
@@ -11205,6 +11208,7 @@ async function resolveAndCompile(data, options = {}) {
|
|
|
11205
11208
|
ttsCli: options.ttsCli,
|
|
11206
11209
|
sttCli: options.sttCli,
|
|
11207
11210
|
subtitleOutputDir: options.subtitleOutputDir,
|
|
11211
|
+
seed: options.seed,
|
|
11208
11212
|
variants: options.variants
|
|
11209
11213
|
});
|
|
11210
11214
|
const compiled = compileDescriptiveRoot(resolved, {
|
package/src/player/pipeline.ts
CHANGED
|
@@ -36,6 +36,9 @@ export interface ResolveAndCompileOptions {
|
|
|
36
36
|
/** Separate output dir for the merged subtitles.vtt (e.g., per-variant).
|
|
37
37
|
* Per-clip VTTs stay in scriptOutputDir; defaults to scriptOutputDir. */
|
|
38
38
|
subtitleOutputDir?: string;
|
|
39
|
+
/** Global seed for reproducible TTI/TTV generation. Applied when the CLI
|
|
40
|
+
* template contains {seed}. Overrides root.seed if set. */
|
|
41
|
+
seed?: number;
|
|
39
42
|
/** Variant chain to apply to include nodes (e.g. ["zh", "tiktok"]).
|
|
40
43
|
* When set, included .md files are parsed with variant awareness. */
|
|
41
44
|
variants?: string[];
|
|
@@ -90,6 +93,7 @@ export async function resolveAndCompile(
|
|
|
90
93
|
ttsCli: options.ttsCli,
|
|
91
94
|
sttCli: options.sttCli,
|
|
92
95
|
subtitleOutputDir: options.subtitleOutputDir,
|
|
96
|
+
seed: options.seed,
|
|
93
97
|
variants: options.variants,
|
|
94
98
|
});
|
|
95
99
|
|
|
@@ -35,24 +35,33 @@ export function extractScenes(root) {
|
|
|
35
35
|
|
|
36
36
|
// Format 1: scenes as direct children of root
|
|
37
37
|
if (root.children?.length && !root.children.find(c => c.name === "scenes" || c.id === "scenes")) {
|
|
38
|
-
|
|
38
|
+
const overlap = root.transition ? (root.transitionTime ?? 0.5) : 0;
|
|
39
|
+
let offset = 0; // cumulative scene durations (without transition overlap)
|
|
39
40
|
for (const s of root.children) {
|
|
40
41
|
if (!(s.type === "folder" || s.type === "scene" || s.children?.length)) continue;
|
|
41
42
|
if (s.isBackground) continue;
|
|
43
|
+
// Use durationInSeconds (set by compiler) as the definitive duration.
|
|
44
|
+
// Fall back to leaf end-start for backward compatibility.
|
|
42
45
|
const leaf = (s.children || []).find(c => c.src && (c.type === "image" || c.type === "video"));
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
let dur = s.durationInSeconds;
|
|
47
|
+
if (!dur || dur <= 0) {
|
|
48
|
+
const src2 = leaf || s;
|
|
49
|
+
dur = (src2.end ?? 5) - (src2.start ?? 0);
|
|
50
|
+
}
|
|
51
|
+
// Account for transition overlap: scene i starts at offset - i*overlap
|
|
52
|
+
const transitionOffset = scenes.length * overlap;
|
|
53
|
+
const start = Math.max(0, offset - transitionOffset);
|
|
45
54
|
scenes.push({
|
|
46
55
|
name: s.name || s.id || "scene",
|
|
47
|
-
start
|
|
48
|
-
end:
|
|
56
|
+
start,
|
|
57
|
+
end: start + dur,
|
|
49
58
|
duration: dur,
|
|
50
59
|
src: leaf?.src || "",
|
|
51
60
|
mediaType: leaf?.type || "unknown",
|
|
52
61
|
});
|
|
53
62
|
offset += dur;
|
|
54
63
|
}
|
|
55
|
-
totalDuration = offset;
|
|
64
|
+
totalDuration = offset - Math.max(0, (scenes.length - 1)) * overlap;
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
// Format 2: scenes wrapped in a "scenes" folder
|
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/schema/index.ts
CHANGED
|
@@ -104,7 +104,6 @@ export const video = base.extend({
|
|
|
104
104
|
playbackRate: z.number().optional(),
|
|
105
105
|
width: z.number().default(1080),
|
|
106
106
|
height: z.number().default(1920),
|
|
107
|
-
loop: z.number().int().min(1).optional().describe(">1 = loop count"),
|
|
108
107
|
});
|
|
109
108
|
export type Video = z.infer<typeof video>;
|
|
110
109
|
|
|
@@ -113,7 +112,6 @@ export const audio = base.extend({
|
|
|
113
112
|
src: z.string().optional(),
|
|
114
113
|
volume: z.number().min(0).max(1).default(1),
|
|
115
114
|
foreground: z.boolean().optional().describe("ducks parent video audio while playing"),
|
|
116
|
-
loop: z.number().int().min(1).optional().describe(">1 = loop count"),
|
|
117
115
|
speaker: z.string().optional().describe("speaker name for multi-turn dialogue; set by resolveDialogue"),
|
|
118
116
|
});
|
|
119
117
|
export type Audio = z.infer<typeof audio>;
|
package/src/types/Audio.tsx
CHANGED
|
@@ -24,7 +24,7 @@ export function AudioLeaf({ stream }: { stream: Audio }) {
|
|
|
24
24
|
const startFrom = stream.startFrom ?? 0;
|
|
25
25
|
const endAt = stream.endAt ?? totalDur;
|
|
26
26
|
const volume = stream.volume ?? 1;
|
|
27
|
-
const playbackRate =
|
|
27
|
+
const playbackRate = toPlaybackRate((endAt - startFrom) / (end - start));
|
|
28
28
|
return (
|
|
29
29
|
<Sequence
|
|
30
30
|
name={stream.src ?? "audio"}
|
|
@@ -39,7 +39,6 @@ export function AudioLeaf({ stream }: { stream: Audio }) {
|
|
|
39
39
|
endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
|
|
40
40
|
muted={volume === 0 || !!ctx?.foreground}
|
|
41
41
|
volume={volume}
|
|
42
|
-
loop={(stream.loop ?? 1) > 1}
|
|
43
42
|
playbackRate={playbackRate}
|
|
44
43
|
showInTimeline={false}
|
|
45
44
|
/>
|
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 }}>
|