@lalalic/markcut 1.1.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/AGENTS.md +39 -15
  2. package/README.md +7 -3
  3. package/SKILL.md +26 -172
  4. package/docs/edit-mode.md +1 -1
  5. package/docs/label-mode.md +6 -4
  6. package/docs/markdown-descriptive.md +46 -1
  7. package/docs/system-prompt-edit.md +16 -0
  8. package/package.json +1 -1
  9. package/src/config.mjs +8 -3
  10. package/src/descriptive/compiler.test.ts +42 -42
  11. package/src/descriptive/compiler.ts +41 -52
  12. package/src/descriptive/markdown.ts +8 -4
  13. package/src/descriptive/resolve.test.ts +14 -7
  14. package/src/descriptive/resolve.ts +148 -20
  15. package/src/entry.tsx +8 -2
  16. package/src/player/browser.tsx +178 -54
  17. package/src/player/bundle/player.js +1168 -566
  18. package/src/player/components/EditControls.tsx +92 -0
  19. package/src/player/components/HeaderBar.tsx +60 -0
  20. package/src/player/components/LabelControls.tsx +367 -0
  21. package/src/player/components/SceneThumbnails.tsx +87 -0
  22. package/src/player/components/VariantBar.tsx +39 -0
  23. package/src/player/components/index.ts +5 -0
  24. package/src/player/pipeline.mjs +130 -66
  25. package/src/player/pipeline.ts +3 -1
  26. package/src/player/server-shared.mjs +5 -7
  27. package/src/player/server.mjs +194 -187
  28. package/src/render/cli.mjs +66 -13
  29. package/src/schema/index.ts +20 -23
  30. package/src/types/Audio.tsx +25 -33
  31. package/src/types/Component.tsx +18 -24
  32. package/src/types/Effect.tsx +31 -39
  33. package/src/types/Image.tsx +23 -30
  34. package/src/types/Include.tsx +70 -76
  35. package/src/types/Map.tsx +48 -44
  36. package/src/types/Rhythm.tsx +19 -27
  37. package/src/types/Video.tsx +40 -47
  38. package/src/utils/component-import-map.ts +93 -0
  39. package/src/utils/index.ts +23 -10
  40. package/src/vision/cli.mjs +6 -6
  41. package/templates/courseware/TEMPLATE.md +317 -0
  42. package/templates/courseware/agents/reviewer.md +84 -0
  43. package/templates/courseware/prompts/fix.md +30 -0
  44. package/templates/courseware/prompts/outline.md +53 -0
  45. package/templates/courseware/prompts/scene.md +64 -0
  46. package/tests/fixtures/audio.json +4 -2
  47. package/tests/fixtures/basic.json +2 -1
  48. package/tests/fixtures/component-all.json +4 -2
  49. package/tests/fixtures/components.json +4 -2
  50. package/tests/fixtures/effects.json +12 -6
  51. package/tests/fixtures/full.json +8 -4
  52. package/tests/fixtures/map.json +2 -1
  53. package/tests/fixtures/md/dialogue.md +12 -0
  54. package/tests/fixtures/md/edge-cases.md +9 -0
  55. package/tests/fixtures/scenes.json +4 -2
  56. package/tests/fixtures/subtitle.json +6 -3
  57. package/tests/fixtures/subvideo.json +11 -6
  58. package/tests/fixtures/templates/courseware.md +61 -4
  59. package/tests/fixtures/tween-visual.json +2 -6
  60. package/tests/fixtures/video-series.json +6 -14
  61. package/tests/md-descriptive.test.ts +170 -0
  62. package/tests/render.test.ts +32 -16
  63. package/tests/schema.test.ts +6 -3
  64. package/tests/server.test.ts +9 -6
  65. package/tests/utils.ts +4 -4
  66. package/docs/dynamic-components.md +0 -191
  67. package/docs/templates.md +0 -52
  68. package/scripts/artlist-dl.mjs +0 -190
  69. package/src/player/label-server.mjs +0 -599
@@ -104,17 +104,20 @@ function useSubVideoRegistry(importsUrl: string | undefined):
104
104
  * registered by the sub-video are available during its rendering.
105
105
  *
106
106
  * Usage:
107
- * { type: "include", src: "./path/to/video.json", actions: [{ start: 0, end: 5 }] }
107
+ * { type: "include", src: "./path/to/video.json", start: 0, end: 5 }
108
108
  */
109
109
  export function IncludeLeaf({ stream }: { stream: IncludeStream }) {
110
110
  const { fps: parentFps, width: parentWidth, height: parentHeight } = useVideoConfig();
111
111
  const parentCompose = React.useContext(ComposeContext);
112
112
  const { Container } = parentCompose;
113
113
  const parentAudio = React.useContext(AudioContext);
114
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
114
+ const start = stream.start ?? 0;
115
+ const end = stream.end ?? (stream.durationInSeconds ?? (start + (stream.duration ?? 1)));
116
+ const totalDur = stream.durationInSeconds ?? end;
115
117
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * parentFps)));
116
118
 
117
- if (!stream.actions?.length) return null;
119
+ const hasContent = !!stream.src || (stream.children?.length ?? 0) > 0;
120
+ if (!hasContent) return null;
118
121
 
119
122
  // ── External JSON loading ─────────────────────────────────────────────
120
123
  const [externalData, setExternalData] = React.useState<unknown | null>(null);
@@ -296,87 +299,78 @@ export function IncludeLeaf({ stream }: { stream: IncludeStream }) {
296
299
  );
297
300
  }, [externalData, loadError, parentFps, parentWidth, parentHeight]);
298
301
 
299
- // ── Action-based rendering ───────────────────────────────────────
300
- const renderAction = (a: IncludeStream["actions"][number]) => {
301
- const start = a.start ?? 0;
302
- const end = a.end ?? (stream.durationInSeconds ?? start + 1);
303
- const dur = Math.max(0.1, end - start);
304
- const durFrames = Math.max(1, Math.floor(parentFps * dur));
305
-
306
- // ── Inline children fallback (legacy) ─────────────────────────
307
- if (!stream.src && stream.children?.length) {
308
- return (
309
- <Sequence
310
- key={a.id}
311
- durationInFrames={durFrames}
312
- from={Math.floor(parentFps * start)}
313
- layout="none"
314
- showInTimeline={false}
315
- >
316
- <Container
317
- id={stream.id}
318
- type="include"
319
- style={{
320
- ...cssJS(stream.style) as React.CSSProperties,
321
- width: parentWidth,
322
- height: parentHeight,
323
- overflow: "hidden",
324
- position: "relative",
325
- }}
326
- className={`include ${toClassName(stream.id ?? "")}`}
327
- >
328
- <FolderLeaf stream={stream as any} />
329
- </Container>
330
- </Sequence>
331
- );
332
- }
333
-
334
- // ── External src rendering ─────────────────────────────────────
335
- return (
336
- <Sequence
337
- key={a.id}
338
- durationInFrames={durFrames}
339
- from={Math.floor(parentFps * start)}
340
- layout="none"
341
- showInTimeline={false}
302
+ // ── Single-span rendering ────────────────────────────────────────
303
+ const dur = Math.max(0.1, end - start);
304
+ const durFrames = Math.max(1, Math.floor(parentFps * dur));
305
+
306
+ // ── Inline children fallback (legacy) ─────────────────────────
307
+ let innerNode: React.ReactNode;
308
+ if (!stream.src && stream.children?.length) {
309
+ innerNode = (
310
+ <Container
311
+ id={stream.id}
312
+ type="include"
313
+ style={{
314
+ ...cssJS(stream.style) as React.CSSProperties,
315
+ width: parentWidth,
316
+ height: parentHeight,
317
+ overflow: "hidden",
318
+ position: "relative",
319
+ }}
320
+ className={`include ${toClassName(stream.id ?? "")}`}
342
321
  >
343
- {externalData || loadError ? (
344
- <div
345
- style={{
346
- width: parentWidth,
347
- height: parentHeight,
348
- overflow: "hidden",
349
- position: "relative",
350
- }}
351
- >
352
- {renderExternalContent()}
353
- </div>
354
- ) : (
355
- <div
356
- style={{
357
- width: parentWidth,
358
- height: parentHeight,
359
- display: "flex",
360
- alignItems: "center",
361
- justifyContent: "center",
362
- color: "#666",
363
- fontSize: 20,
364
- fontFamily: "monospace",
365
- }}
366
- >
367
- Loading… {stream.src}
368
- </div>
369
- )}
370
- </Sequence>
322
+ <FolderLeaf stream={stream as any} />
323
+ </Container>
371
324
  );
372
- };
325
+ } else if (externalData || loadError) {
326
+ innerNode = (
327
+ <div
328
+ style={{
329
+ width: parentWidth,
330
+ height: parentHeight,
331
+ overflow: "hidden",
332
+ position: "relative",
333
+ }}
334
+ >
335
+ {renderExternalContent()}
336
+ </div>
337
+ );
338
+ } else {
339
+ innerNode = (
340
+ <div
341
+ style={{
342
+ width: parentWidth,
343
+ height: parentHeight,
344
+ display: "flex",
345
+ alignItems: "center",
346
+ justifyContent: "center",
347
+ color: "#666",
348
+ fontSize: 20,
349
+ fontFamily: "monospace",
350
+ }}
351
+ >
352
+ Loading… {stream.src}
353
+ </div>
354
+ );
355
+ }
356
+
357
+ const sequence = (
358
+ <Sequence
359
+ durationInFrames={durFrames}
360
+ from={Math.floor(parentFps * start)}
361
+ layout="none"
362
+ showInTimeline={false}
363
+ >
364
+ {innerNode}
365
+ </Sequence>
366
+ );
373
367
 
374
368
  // ── Determine if we need a nested ComposeContext ───────────────────
375
369
  const needsNestedContext = subRegistry !== null && subRegistry !== parentCompose.components;
376
370
 
377
371
  const inner = (
378
372
  <AudioContext.Provider value={audioCtx as any}>
379
- {stream.actions.map(renderAction)}
373
+ {sequence}
380
374
  </AudioContext.Provider>
381
375
  );
382
376
 
package/src/types/Map.tsx CHANGED
@@ -15,7 +15,7 @@
15
15
  * travelMode: "DRIVING", // DRIVING | WALKING | BICYCLING
16
16
  * mapType: "roadmap", // roadmap | satellite | hybrid | terrain
17
17
  * routeMarker: "🚗", // emoji/char for animated pin
18
- * actions: [{ start: 0, end: 5 }]
18
+ * start: 0, end: 5
19
19
  * }
20
20
  */
21
21
  import React from "react";
@@ -27,7 +27,17 @@ import {
27
27
  } from "@vis.gl/react-google-maps";
28
28
  import type { MapStream } from "../schema/index";
29
29
 
30
- const GM_API_KEY = process.env.GOOGLE_MAPS_API_KEY || "";
30
+ // API key is injected by the compiler onto the stream node (see compileLeaf in compiler.ts).
31
+ // This fallback handles the case where Map.tsx is used directly without the compiler.
32
+ function resolveApiKey(stream: MapStream): string {
33
+ if (stream.googleMapsApiKey) return stream.googleMapsApiKey;
34
+ // Safe fallback for Node.js contexts (e.g. remotion render without compiler).
35
+ // In the browser player the key is always pre-stamped by the server-side compiler.
36
+ if (typeof process !== "undefined" && typeof process.env !== "undefined" && process.env.GOOGLE_MAPS_API_KEY) {
37
+ return process.env.GOOGLE_MAPS_API_KEY;
38
+ }
39
+ return "";
40
+ }
31
41
 
32
42
  // ============================================================
33
43
  // MapLeaf — entry point, renders each action as a Sequence
@@ -35,52 +45,46 @@ const GM_API_KEY = process.env.GOOGLE_MAPS_API_KEY || "";
35
45
  export function MapLeaf({ stream }: { stream: MapStream }) {
36
46
  const { fps } = useVideoConfig();
37
47
  const waypoints = stream.waypoints ?? [];
38
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
48
+ const start = stream.start ?? 0;
49
+ const end = stream.end ?? start + (stream.duration ?? 1);
50
+ const totalDur = stream.durationInSeconds ?? end;
51
+ const apiKey = resolveApiKey(stream);
39
52
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
40
53
  if (waypoints.length === 0) return null;
41
54
 
55
+ const durFrames = Math.max(1, Math.floor(fps * (end - start)));
56
+ const center = stream.center ?? { lat: waypoints[0].lat, lng: waypoints[0].lng };
57
+ const zoom = stream.zoom ?? 10;
58
+ const mapType = stream.mapType ?? "roadmap";
59
+ const travelMode = stream.travelMode ?? "DRIVING";
60
+ const markerEmoji = stream.routeMarker ?? "🚗";
42
61
  return (
43
- <>
44
- {stream.actions?.map((a, i) => {
45
- const start = a.start ?? 0;
46
- const end = a.end ?? start + 1;
47
- const durFrames = Math.max(1, Math.floor(fps * (end - start)));
48
- const center = stream.center ?? { lat: waypoints[0].lat, lng: waypoints[0].lng };
49
- const zoom = stream.zoom ?? 10;
50
- const mapType = stream.mapType ?? "roadmap";
51
- const travelMode = stream.travelMode ?? "DRIVING";
52
- const markerEmoji = stream.routeMarker ?? "🚗";
53
- return (
54
- <Sequence
55
- key={a.id ?? i}
56
- durationInFrames={durFrames}
57
- from={Math.floor(fps * start)}
58
- layout="none"
59
- >
60
- <APIProvider apiKey={GM_API_KEY}>
61
- <GoogleMap
62
- mapId={String(stream.id ?? i)}
63
- defaultCenter={center}
64
- defaultZoom={zoom}
65
- defaultOptions={{
66
- mapTypeId: mapType,
67
- disableDefaultUI: true,
68
- zoomControl: false,
69
- }}
70
- style={{ width: "100%", height: "100%", position: "absolute" }}
71
- >
72
- <RouteWithMarker
73
- waypoints={waypoints}
74
- travelMode={travelMode}
75
- markerEmoji={markerEmoji}
76
- actionDuration={end - start}
77
- />
78
- </GoogleMap>
79
- </APIProvider>
80
- </Sequence>
81
- );
82
- })}
83
- </>
62
+ <Sequence
63
+ durationInFrames={durFrames}
64
+ from={Math.floor(fps * start)}
65
+ layout="none"
66
+ >
67
+ <APIProvider apiKey={apiKey}>
68
+ <GoogleMap
69
+ mapId={String(stream.id ?? "map")}
70
+ defaultCenter={center}
71
+ defaultZoom={zoom}
72
+ defaultOptions={{
73
+ mapTypeId: mapType,
74
+ disableDefaultUI: true,
75
+ zoomControl: false,
76
+ }}
77
+ style={{ width: "100%", height: "100%", position: "absolute" }}
78
+ >
79
+ <RouteWithMarker
80
+ waypoints={waypoints}
81
+ travelMode={travelMode}
82
+ markerEmoji={markerEmoji}
83
+ actionDuration={end - start}
84
+ />
85
+ </GoogleMap>
86
+ </APIProvider>
87
+ </Sequence>
84
88
  );
85
89
  }
86
90
 
@@ -21,38 +21,30 @@ function resolveAudioSrc(src: string): string {
21
21
  export function RhythmLeaf({ stream }: { stream: Rhythm }) {
22
22
  const { fps } = useVideoConfig();
23
23
  const environment = useRemotionEnvironment();
24
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
24
+ const start = stream.start ?? 0;
25
+ const end = stream.end ?? start + (stream.duration ?? 1);
26
+ const totalDur = stream.durationInSeconds ?? end;
25
27
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
26
28
 
27
29
  if (!stream.src || environment.isStudio) return null;
28
30
 
29
31
  const resolvedSrc = resolveAudioSrc(stream.src);
30
-
32
+ const volume = stream.volume ?? 1;
31
33
  return (
32
- <>
33
- {stream.actions.map((a) => {
34
- const start = a.start ?? 0;
35
- const end = a.end ?? start + 1;
36
- const volume = a.volume ?? stream.volume ?? 1;
37
- return (
38
- <Sequence
39
- key={a.id}
40
- name={stream.src ?? "rhythm"}
41
- durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
42
- from={Math.floor(fps * start)}
43
- layout="none"
44
- showInTimeline={false}
45
- >
46
- <RemotionAudio
47
- src={resolvedSrc}
48
- muted={volume === 0}
49
- volume={volume}
50
- loop
51
- showInTimeline={false}
52
- />
53
- </Sequence>
54
- );
55
- })}
56
- </>
34
+ <Sequence
35
+ name={stream.src ?? "rhythm"}
36
+ durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
37
+ from={Math.floor(fps * start)}
38
+ layout="none"
39
+ showInTimeline={false}
40
+ >
41
+ <RemotionAudio
42
+ src={resolvedSrc}
43
+ muted={volume === 0}
44
+ volume={volume}
45
+ loop
46
+ showInTimeline={false}
47
+ />
48
+ </Sequence>
57
49
  );
58
50
  }
@@ -13,58 +13,51 @@ import { FrameSyncStyle } from "./FrameSyncStyle";
13
13
  export function VideoLeaf({ stream }: { stream: Video }) {
14
14
  const { fps } = useVideoConfig();
15
15
  const audio = React.useContext(AudioContext);
16
- const totalDur = stream.durationInSeconds ?? stream.actions[0]?.end ?? 1;
16
+ const start = stream.start ?? 0;
17
+ const end = stream.end ?? start + (stream.duration ?? 1);
18
+ const totalDur = stream.durationInSeconds ?? end;
17
19
  useFrameEvents(stream.on, Math.max(1, Math.floor(totalDur * fps)));
18
20
  if (!stream.src) return null;
19
21
  const resolvedSrc = resolveVideoSrc(stream.src);
20
22
 
23
+ const startFrom = stream.startFrom ?? 0;
24
+ const endAt = stream.endAt ?? totalDur;
25
+ const volume = stream.volume ?? 1;
26
+ const playbackRate = stream.loop ? 1 : Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
27
+ const streamStyle = cssJS(stream.style);
28
+ const hasAnimation = "animation" in streamStyle;
21
29
  return (
22
- <>
23
- {stream.actions.map((a) => {
24
- const start = a.start ?? 0;
25
- const end = a.end ?? start + 1;
26
- const startFrom = a.startFrom ?? 0;
27
- const endAt = a.endAt ?? stream.durationInSeconds ?? end - start;
28
- const volume = a.volume ?? stream.volume ?? 1;
29
- const playbackRate = a.loop ? 1 : Math.min(1, toPlaybackRate((endAt - startFrom) / (end - start)));
30
- const actionStyle = cssJS(a.style);
31
- const hasAnimation = "animation" in actionStyle;
32
- return (
33
- <Sequence
34
- key={a.id}
35
- durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
36
- from={Math.floor(fps * start)}
37
- layout="none"
30
+ <Sequence
31
+ durationInFrames={Math.max(1, Math.floor(fps * (end - start)))}
32
+ from={Math.floor(fps * start)}
33
+ layout="none"
34
+ showInTimeline={false}
35
+ >
36
+ {hasAnimation ? (
37
+ <FrameSyncStyle style={streamStyle}>
38
+ <OffthreadVideo
39
+ src={resolvedSrc}
40
+ startFrom={Math.floor(startFrom * fps)}
41
+ endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
42
+ muted={volume === 0 || !!audio?.foreground}
43
+ volume={volume}
44
+ playbackRate={playbackRate}
38
45
  showInTimeline={false}
39
- >
40
- {hasAnimation ? (
41
- <FrameSyncStyle style={actionStyle}>
42
- <OffthreadVideo
43
- src={resolvedSrc}
44
- startFrom={Math.floor(startFrom * fps)}
45
- endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
46
- muted={volume === 0 || !!audio?.foreground}
47
- volume={volume}
48
- playbackRate={playbackRate}
49
- showInTimeline={false}
50
- style={{ width: "100%", height: "100%" }}
51
- />
52
- </FrameSyncStyle>
53
- ) : (
54
- <OffthreadVideo
55
- src={resolvedSrc}
56
- startFrom={Math.floor(startFrom * fps)}
57
- endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
58
- muted={volume === 0 || !!audio?.foreground}
59
- volume={volume}
60
- playbackRate={playbackRate}
61
- showInTimeline={false}
62
- style={{ width: "100%", height: "100%", ...actionStyle }}
63
- />
64
- )}
65
- </Sequence>
66
- );
67
- })}
68
- </>
46
+ style={{ width: "100%", height: "100%" }}
47
+ />
48
+ </FrameSyncStyle>
49
+ ) : (
50
+ <OffthreadVideo
51
+ src={resolvedSrc}
52
+ startFrom={Math.floor(startFrom * fps)}
53
+ endAt={Math.floor(startFrom * fps) + Math.floor(((endAt - startFrom) * fps) / playbackRate)}
54
+ muted={volume === 0 || !!audio?.foreground}
55
+ volume={volume}
56
+ playbackRate={playbackRate}
57
+ showInTimeline={false}
58
+ style={{ width: "100%", height: "100%", ...streamStyle }}
59
+ />
60
+ )}
61
+ </Sequence>
69
62
  );
70
63
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Shared-module import map for dynamically loaded component bundles.
3
+ *
4
+ * Component bundles are built with react/react-dom/remotion marked as
5
+ * --external (see player/bundler.mjs getSharedExternals), so their output
6
+ * contains bare imports like `import { useState } from "react"`. The page
7
+ * that dynamically imports such a bundle must provide an import map that
8
+ * resolves those bare specifiers back to the already-loaded module
9
+ * instances — otherwise the import fails ("Failed to resolve module
10
+ * specifier") or, worse, a duplicate React breaks hooks and context.
11
+ *
12
+ * The preview player injects this map itself (player/browser.tsx). This
13
+ * helper provides the same shim for the Remotion render page (headless
14
+ * Chrome during `markcut render`). Both use the `#rmtr-import-map` guard,
15
+ * so whichever runs first wins and the other becomes a no-op.
16
+ */
17
+ import * as React from "react";
18
+ import * as ReactDOM from "react-dom";
19
+ import * as Remotion from "remotion";
20
+
21
+ export function ensureSharedImportMap(): void {
22
+ if (typeof document === "undefined") return;
23
+ if (document.querySelector("#rmtr-import-map")) return;
24
+
25
+ const g = globalThis as any;
26
+ g.__remotionShared ??= {};
27
+ g.__remotionShared["react"] ??= React;
28
+ g.__remotionShared["react-dom"] ??= ReactDOM;
29
+ g.__remotionShared["remotion"] ??= Remotion;
30
+
31
+ // Stub for node:* imports that some component deps reference at module
32
+ // level for build-time helpers never called during rendering.
33
+ if (!g.__nodeModuleStub) {
34
+ const nodeModuleStubCode = `export function createRequire() { return () => ({ resolve: () => { throw new Error("node:module.createRequire is not available in the browser"); } }); }`;
35
+ g.__nodeModuleStub = URL.createObjectURL(
36
+ new Blob([nodeModuleStubCode], { type: "application/javascript" }),
37
+ );
38
+ }
39
+
40
+ try {
41
+ const imports: Record<string, string> = {};
42
+ for (const spec of ["node:module", "node:fs", "node:path", "node:os"]) {
43
+ imports[spec] = g.__nodeModuleStub;
44
+ }
45
+
46
+ for (const [specifier, mod] of Object.entries(g.__remotionShared) as [string, any][]) {
47
+ const lines: string[] = [
48
+ `const _mod = globalThis.__remotionShared[${JSON.stringify(specifier)}];`,
49
+ ];
50
+ for (const name of Object.keys(mod)) {
51
+ if (name === "default") continue;
52
+ lines.push(
53
+ `const __${name} = _mod[${JSON.stringify(name)}];`,
54
+ `export { __${name} as ${name} };`,
55
+ );
56
+ }
57
+ if ("default" in mod) lines.push("export default _mod.default;");
58
+ imports[specifier] = URL.createObjectURL(
59
+ new Blob([lines.join("\n")], { type: "application/javascript" }),
60
+ );
61
+ }
62
+
63
+ // react/jsx-runtime — built manually because it's a sub-path of react.
64
+ const blobJsx = new Blob(
65
+ [
66
+ `
67
+ const R = globalThis.__remotionShared["react"];
68
+ const { createElement, Fragment } = R;
69
+ export { Fragment };
70
+ export function jsx(type, props, key) {
71
+ return createElement(type, key != null ? { ...props, key } : props);
72
+ }
73
+ export function jsxs(type, props, key) {
74
+ return createElement(type, key != null ? { ...props, key } : props);
75
+ }
76
+ export function jsxDEV(type, props, key, isStaticChildren, source, self) {
77
+ return createElement(type, key != null ? { ...props, key } : props);
78
+ }
79
+ `,
80
+ ],
81
+ { type: "application/javascript" },
82
+ );
83
+ imports["react/jsx-runtime"] = URL.createObjectURL(blobJsx);
84
+
85
+ const script = document.createElement("script");
86
+ script.type = "importmap";
87
+ script.id = "rmtr-import-map";
88
+ script.textContent = JSON.stringify({ imports });
89
+ document.head.appendChild(script);
90
+ } catch (e) {
91
+ console.warn("Failed to create shared module import map:", e);
92
+ }
93
+ }
@@ -57,11 +57,12 @@ export function walkDown<T extends StreamNode>(
57
57
  * Compute duration of a stream subtree, in seconds.
58
58
  *
59
59
  * - rhythm streams use their pre-set durationInSeconds (set by host)
60
- * - leaf actions use action.end as default
60
+ * - leaf duration uses base `end` (or `start + duration`) via leafEnd()
61
61
  * - series sums children, subtracting transition overlaps
62
62
  * - sequence (parallel) takes max child duration
63
63
  * - background children do not contribute
64
- * - actions[].streamRef is unsupported in lite (no template instancing)
64
+ * - leaf duration uses base `end` (or `start + duration`) via leafEnd()
65
+ * - streamRef/template instancing is unsupported in lite
65
66
  */
66
67
  export interface DurationStream extends StreamNode {
67
68
  type?: string;
@@ -70,10 +71,24 @@ export interface DurationStream extends StreamNode {
70
71
  transition?: string;
71
72
  transitionTime?: number;
72
73
  durationInSeconds?: number;
73
- actions?: Array<{ start?: number; end?: number; startFrom?: number; endAt?: number }>;
74
+ start?: number;
75
+ end?: number;
76
+ duration?: number;
74
77
  children?: DurationStream[];
75
78
  }
76
79
 
80
+ /**
81
+ * Resolve a leaf node's effective end time from its base timing fields.
82
+ * `end` is the source of truth; `duration` is a convenience that normalizes
83
+ * to `start + duration` when `end` is absent. Returns 0 when neither is set.
84
+ */
85
+ export function leafEnd(stream: { start?: number; end?: number; duration?: number }): number {
86
+ if (typeof stream.end === "number") return stream.end;
87
+ const s = stream.start ?? 0;
88
+ if (typeof stream.duration === "number") return s + stream.duration;
89
+ return 0;
90
+ }
91
+
77
92
  export function getDurationInSeconds(stream: DurationStream, update = true): number {
78
93
  if (!stream) return 0;
79
94
 
@@ -81,13 +96,12 @@ export function getDurationInSeconds(stream: DurationStream, update = true): num
81
96
  return stream.durationInSeconds ?? 0;
82
97
  }
83
98
 
84
- // include: if src is set, treat as leaf (duration from action end).
99
+ // include: if src is set, treat as leaf (duration from base end).
85
100
  // Otherwise fall back to inline children (legacy).
86
101
  if (stream.type === "include") {
87
102
  if (stream.src) {
88
- // External reference — use action end (like other leaves)
89
- const last = stream.actions?.[stream.actions.length - 1];
90
- const d = last?.end ?? 0;
103
+ // External reference — use base end (like other leaves)
104
+ const d = leafEnd(stream);
91
105
  if (update) stream.durationInSeconds = d;
92
106
  return d;
93
107
  }
@@ -109,10 +123,9 @@ export function getDurationInSeconds(stream: DurationStream, update = true): num
109
123
  }
110
124
 
111
125
  // scene is a container (like folder) — falls through to general children logic
112
- // leaf with actions
126
+ // leaf with base timing fields (start/end/duration)
113
127
  if (!stream.children?.length) {
114
- const last = stream.actions?.[stream.actions.length - 1];
115
- const d = last?.end ?? 0;
128
+ const d = leafEnd(stream);
116
129
  if (update) stream.durationInSeconds = d;
117
130
  return d;
118
131
  }
@@ -662,7 +662,7 @@ function buildPreviewTree(folder, metadata) {
662
662
  id: `${e.name}-media`,
663
663
  type: e.isVideo ? "video" : "image",
664
664
  src: e.relSrc, fit: "cover",
665
- actions: [{ start: 0, end: e.dur }],
665
+ start: 0, end: e.dur,
666
666
  // Preserve existing labels from metadata so re-labeling shows them
667
667
  userHints: e.userHints || undefined,
668
668
  }],
@@ -943,9 +943,9 @@ async function runFullPipeline(folder, prompts, ittCli, vttCli, sttCli, context,
943
943
  const previewJsonPath = join(folder, ".preview.json");
944
944
  writeFileSync(previewJsonPath, JSON.stringify(previewTree, null, 2), "utf-8");
945
945
 
946
- const labelServer = join(__dirname, "..", "player", "label-server.mjs");
947
- if (!existsSync(labelServer)) {
948
- emitError(`Label server not found at ${labelServer}`);
946
+ const playerServer = join(__dirname, "..", "player", "server.mjs");
947
+ if (!existsSync(playerServer)) {
948
+ emitError(`Player server not found at ${playerServer}`);
949
949
  process.exit(1);
950
950
  }
951
951
 
@@ -954,7 +954,7 @@ async function runFullPipeline(folder, prompts, ittCli, vttCli, sttCli, context,
954
954
  emitInfo(` Labels will be merged into metadata.json as user hints.\n`);
955
955
 
956
956
  const port = 3031;
957
- const child = spawn("node", [labelServer, previewJsonPath, `--port=${port}`], {
957
+ const child = spawn("node", [playerServer, previewJsonPath, "--label", `--port=${port}`], {
958
958
  cwd: resolve(__dirname, "..", ".."),
959
959
  stdio: ["ignore", "pipe", "inherit"],
960
960
  });
@@ -963,7 +963,7 @@ async function runFullPipeline(folder, prompts, ittCli, vttCli, sttCli, context,
963
963
  if (child.stdout) {
964
964
  child.stdout.on("data", (chunk) => {
965
965
  process.stdout.write(chunk);
966
- if (!serverReady && chunk.toString().includes("Label Preview")) {
966
+ if (!serverReady && chunk.toString().includes("Player ready")) {
967
967
  serverReady = true;
968
968
  try { execSync(`open http://localhost:${port}`, { stdio: "ignore" }); } catch {}
969
969
  }