@scenar/remotion 0.10.0 → 0.11.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.
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
- import type { ScenarioBundle } from "@scenar/core";
2
+ import { type ScenarioBundle, type SfxSound } from "@scenar/core";
3
3
  interface ScenarioCompositionProps<T> {
4
4
  /** Self-contained scenario bundle with steps and optional narration. */
5
5
  bundle: ScenarioBundle<T>;
@@ -14,6 +14,20 @@ interface ScenarioCompositionProps<T> {
14
14
  * Defaults to true.
15
15
  */
16
16
  useStaticFile?: boolean;
17
+ /**
18
+ * Burn step captions into the video: each step's `narration` text
19
+ * renders as a subtitle-style overlay. Forwarded to `ScenarioPlayer` —
20
+ * the same overlay the interactive embed shows, so both outputs caption
21
+ * identically. Defaults to false.
22
+ */
23
+ captions?: boolean;
24
+ /**
25
+ * Asset paths for the built-in SFX set when `bundle.soundtrack.sfx` is
26
+ * enabled, resolved like every other audio src (through `staticFile`
27
+ * when `useStaticFile` is on). Defaults to the locations `scenar
28
+ * render` stages them at (`soundtrack/sfx/<name>.mp3`).
29
+ */
30
+ sfxSrcs?: Record<SfxSound, string>;
17
31
  }
18
32
  /**
19
33
  * Root component for rendering a Scenar scenario as a Remotion video.
@@ -57,6 +71,6 @@ interface ScenarioCompositionProps<T> {
57
71
  * );
58
72
  * ```
59
73
  */
60
- export declare function ScenarioComposition<T>({ bundle, children, fps: fpsProp, useStaticFile: useStaticFileProp, }: ScenarioCompositionProps<T>): import("react/jsx-runtime").JSX.Element;
74
+ export declare function ScenarioComposition<T>({ bundle, children, fps: fpsProp, useStaticFile: useStaticFileProp, captions, sfxSrcs, }: ScenarioCompositionProps<T>): import("react/jsx-runtime").JSX.Element;
61
75
  export {};
62
76
  //# sourceMappingURL=ScenarioComposition.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ScenarioComposition.d.ts","sourceRoot":"","sources":["../src/ScenarioComposition.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAQvC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAMnD,UAAU,wBAAwB,CAAC,CAAC;IAClC,wEAAwE;IACxE,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC;IAC1B,mEAAmE;IACnE,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,SAAS,EAAE,MAAM,KAAK,SAAS,CAAC;IACpD,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,EACrC,MAAM,EACN,QAAQ,EACR,GAAG,EAAE,OAAO,EACZ,aAAa,EAAE,iBAAwB,GACxC,EAAE,wBAAwB,CAAC,CAAC,CAAC,2CAqC7B"}
1
+ {"version":3,"file":"ScenarioComposition.d.ts","sourceRoot":"","sources":["../src/ScenarioComposition.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAW,MAAM,OAAO,CAAC;AAShD,OAAO,EACL,KAAK,cAAc,EACnB,KAAK,QAAQ,EAId,MAAM,cAAc,CAAC;AA4BtB,UAAU,wBAAwB,CAAC,CAAC;IAClC,wEAAwE;IACxE,MAAM,EAAE,cAAc,CAAC,CAAC,CAAC,CAAC;IAC1B,mEAAmE;IACnE,QAAQ,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,SAAS,EAAE,MAAM,KAAK,SAAS,CAAC;IACpD,0CAA0C;IAC1C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;CACpC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,EACrC,MAAM,EACN,QAAQ,EACR,GAAG,EAAE,OAAO,EACZ,aAAa,EAAE,iBAAwB,EACvC,QAAgB,EAChB,OAA0B,GAC3B,EAAE,wBAAwB,CAAC,CAAC,CAAC,2CA6I7B"}
@@ -1,8 +1,25 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Audio, Sequence, staticFile, useCurrentFrame, useVideoConfig, } from "remotion";
3
- import { TimeSourceProvider, VideoExportProvider, ScenarioPlayer } from "@scenar/react";
4
- import { useScenarioTimeline } from "./useScenarioTimeline.js";
2
+ import { useMemo } from "react";
3
+ import { Audio, OffthreadVideo, Sequence, staticFile, useCurrentFrame, useVideoConfig, } from "remotion";
4
+ import { computeMusicEnvelope, deriveSfxTimeline, musicGainAt, } from "@scenar/core";
5
+ import { TimeSourceProvider, VideoExportProvider, ScenarioPlayer, } from "@scenar/react";
6
+ import { msToFrames, useScenarioTimeline } from "./useScenarioTimeline.js";
5
7
  const DEFAULT_FPS = 30;
8
+ /**
9
+ * Frame window an SFX `<Sequence>` stays mounted. Comfortably longer than
10
+ * the ~35 ms synthesized ticks (plus MP3 encoder padding), short enough
11
+ * that rapid typing never accumulates hundreds of live audio tags.
12
+ */
13
+ const SFX_WINDOW_MS = 500;
14
+ /**
15
+ * Default staged locations of the built-in SFX set, mirroring where
16
+ * `scenar render` stages `@scenar/react`'s assets in the public dir.
17
+ * Resolved through the same `staticFile` path as every other audio src.
18
+ */
19
+ const DEFAULT_SFX_SRCS = {
20
+ click: "soundtrack/sfx/click.mp3",
21
+ keystroke: "soundtrack/sfx/keystroke.mp3",
22
+ };
6
23
  /**
7
24
  * Root component for rendering a Scenar scenario as a Remotion video.
8
25
  *
@@ -45,23 +62,83 @@ const DEFAULT_FPS = 30;
45
62
  * );
46
63
  * ```
47
64
  */
48
- export function ScenarioComposition({ bundle, children, fps: fpsProp, useStaticFile: useStaticFileProp = true, }) {
65
+ export function ScenarioComposition({ bundle, children, fps: fpsProp, useStaticFile: useStaticFileProp = true, captions = false, sfxSrcs = DEFAULT_SFX_SRCS, }) {
49
66
  const videoConfig = useVideoConfig();
50
67
  const fps = fpsProp ?? videoConfig.fps ?? DEFAULT_FPS;
51
68
  const frame = useCurrentFrame();
52
69
  const timeline = useScenarioTimeline(bundle.steps, bundle.narrationManifest, fps);
53
70
  const currentTimeMs = (frame / fps) * 1000;
54
- return (_jsxs(_Fragment, { children: [_jsx(TimeSourceProvider, { currentTimeMs: currentTimeMs, stepStartTimesMs: timeline.stepStartTimesMs, children: _jsx(VideoExportProvider, { children: _jsx(ScenarioPlayer, { bundle: bundle, children: children }) }) }), timeline.audioClips.map((clip) => (_jsx(Sequence, { from: clip.startFrame, durationInFrames: clip.durationFrames, children: _jsx(Audio, { src: resolveAudioSrc(clip, useStaticFileProp) }) }, clip.stepIndex)))] }));
71
+ // Synthesized card steps may carry a logo asset. Like every audio src,
72
+ // the authored path (e.g. "./logo.png") points at a file `scenar render`
73
+ // stages into the public dir, so it resolves through the same
74
+ // staticFile path before the player renders it.
75
+ const playerBundle = useMemo(() => {
76
+ if (!bundle.steps.some((step) => step.card?.logoSrc))
77
+ return bundle;
78
+ return {
79
+ ...bundle,
80
+ steps: bundle.steps.map((step) => step.card?.logoSrc
81
+ ? {
82
+ ...step,
83
+ card: {
84
+ ...step.card,
85
+ logoSrc: resolveAssetSrc(step.card.logoSrc, useStaticFileProp),
86
+ },
87
+ }
88
+ : step),
89
+ };
90
+ }, [bundle, useStaticFileProp]);
91
+ const soundtrack = bundle.soundtrack;
92
+ // Music level per frame — the same pure @scenar/core envelope the
93
+ // browser player automates through its gain node, evaluated here as a
94
+ // Remotion volume function so both outputs duck identically.
95
+ const musicEnvelope = useMemo(() => soundtrack?.musicSrc
96
+ ? computeMusicEnvelope(bundle.steps, bundle.narrationManifest, soundtrack)
97
+ : null, [soundtrack, bundle.steps, bundle.narrationManifest]);
98
+ const musicVolume = useMemo(() => musicEnvelope
99
+ ? (f) => musicGainAt(musicEnvelope, (f / fps) * 1000)
100
+ : undefined, [musicEnvelope, fps]);
101
+ // The presenter media slot for the export time domain (T02 decision
102
+ // G2-5): the player owns the PiP frame and its fade; this renderer
103
+ // fills the frame with a frame-locked clip. `layout="none"` nests the
104
+ // Sequence inside the player's DOM without an absolute-fill wrapper;
105
+ // `from` is composition-absolute because the player tree sits outside
106
+ // any other Sequence. Muted — narration remains the only audio.
107
+ const presenterMedia = useMemo(() => {
108
+ if (!bundle.presenterManifest)
109
+ return undefined;
110
+ const renderer = ({ src, window: clipWindow }) => (_jsx(Sequence, { layout: "none", from: msToFrames(clipWindow.startMs, fps), durationInFrames: Math.max(1, msToFrames(clipWindow.clipDurationMs, fps)), children: _jsx(OffthreadVideo, { muted: true, src: resolveAssetSrc(src, useStaticFileProp), style: { width: "100%", height: "100%", objectFit: "cover" } }) }));
111
+ return renderer;
112
+ }, [bundle.presenterManifest, fps, useStaticFileProp]);
113
+ // Derived SFX events converted from step-relative offsets to absolute
114
+ // frames via the shared step starts — the export-side consumer of the
115
+ // same `deriveSfxTimeline` the browser scheduler plays.
116
+ const sfxClips = useMemo(() => {
117
+ if (soundtrack?.sfx !== true)
118
+ return [];
119
+ return deriveSfxTimeline(bundle.steps, bundle.narrationManifest).map((event, index) => ({
120
+ key: `sfx-${index}`,
121
+ startFrame: msToFrames((timeline.stepStartTimesMs[event.stepIndex] ?? 0) + event.offsetMs, fps),
122
+ src: sfxSrcs[event.sound],
123
+ }));
124
+ }, [soundtrack, bundle.steps, bundle.narrationManifest, timeline, fps, sfxSrcs]);
125
+ return (_jsxs(_Fragment, { children: [_jsx(TimeSourceProvider, { currentTimeMs: currentTimeMs, stepStartTimesMs: timeline.stepStartTimesMs, children: _jsx(VideoExportProvider, { presenterMedia: presenterMedia, children: _jsx(ScenarioPlayer, { bundle: playerBundle, captions: captions, children: children }) }) }), timeline.audioClips.map((clip) => (_jsx(Sequence, { from: clip.startFrame, durationInFrames: clip.durationFrames, children: _jsx(Audio, { src: resolveAssetSrc(clip.src, useStaticFileProp) }) }, clip.stepIndex))), soundtrack?.musicSrc && (
126
+ // The music spans the whole composition: `loop` folds a shorter
127
+ // asset seamlessly; the volume function carries fade-in, ducking,
128
+ // and the closing fade-out, so no trim math is needed here.
129
+ _jsx(Audio, { loop: true, src: resolveAssetSrc(soundtrack.musicSrc, useStaticFileProp), volume: musicVolume })), sfxClips.map((clip) => (_jsx(Sequence, { from: clip.startFrame, durationInFrames: msToFrames(SFX_WINDOW_MS, fps), children: _jsx(Audio, { src: resolveAssetSrc(clip.src, useStaticFileProp) }) }, clip.key)))] }));
55
130
  }
56
131
  /**
57
- * Resolve audio source URL. When `useStaticFile` is true, paths are
58
- * resolved through Remotion's `staticFile()` (strips leading `/`).
59
- * This matches Stigmer's pattern: `staticFile(clip.src.replace(/^\//, ""))`.
132
+ * Resolve a staged asset URL (audio clips, card logos). When
133
+ * `useStaticFile` is true, paths are resolved through Remotion's
134
+ * `staticFile()`, stripping a leading `/` (Stigmer's original pattern)
135
+ * or `./` (the form `scenar narrate`, soundtrack, and title-card
136
+ * configs write) so the path lands inside the staged public dir.
60
137
  */
61
- function resolveAudioSrc(clip, useStaticFileFn) {
138
+ function resolveAssetSrc(src, useStaticFileFn) {
62
139
  if (useStaticFileFn) {
63
- return staticFile(clip.src.replace(/^\//, ""));
140
+ return staticFile(src.replace(/^(\.\/|\/)/, ""));
64
141
  }
65
- return clip.src;
142
+ return src;
66
143
  }
67
144
  //# sourceMappingURL=ScenarioComposition.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ScenarioComposition.js","sourceRoot":"","sources":["../src/ScenarioComposition.tsx"],"names":[],"mappings":";AACA,OAAO,EACL,KAAK,EACL,QAAQ,EACR,UAAU,EACV,eAAe,EACf,cAAc,GACf,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACxF,OAAO,EAAE,mBAAmB,EAAkB,MAAM,0BAA0B,CAAC;AAE/E,MAAM,WAAW,GAAG,EAAE,CAAC;AAkBvB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,MAAM,UAAU,mBAAmB,CAAI,EACrC,MAAM,EACN,QAAQ,EACR,GAAG,EAAE,OAAO,EACZ,aAAa,EAAE,iBAAiB,GAAG,IAAI,GACX;IAC5B,MAAM,WAAW,GAAG,cAAc,EAAE,CAAC;IACrC,MAAM,GAAG,GAAG,OAAO,IAAI,WAAW,CAAC,GAAG,IAAI,WAAW,CAAC;IACtD,MAAM,KAAK,GAAG,eAAe,EAAE,CAAC;IAEhC,MAAM,QAAQ,GAAG,mBAAmB,CAClC,MAAM,CAAC,KAAK,EACZ,MAAM,CAAC,iBAAiB,EACxB,GAAG,CACJ,CAAC;IAEF,MAAM,aAAa,GAAG,CAAC,KAAK,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC;IAE3C,OAAO,CACL,8BACE,KAAC,kBAAkB,IACjB,aAAa,EAAE,aAAa,EAC5B,gBAAgB,EAAE,QAAQ,CAAC,gBAAgB,YAE3C,KAAC,mBAAmB,cAClB,KAAC,cAAc,IAAC,MAAM,EAAE,MAAM,YAC3B,QAAQ,GACM,GACG,GACH,EAEpB,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CACjC,KAAC,QAAQ,IAEP,IAAI,EAAE,IAAI,CAAC,UAAU,EACrB,gBAAgB,EAAE,IAAI,CAAC,cAAc,YAErC,KAAC,KAAK,IAAC,GAAG,EAAE,eAAe,CAAC,IAAI,EAAE,iBAAiB,CAAC,GAAI,IAJnD,IAAI,CAAC,SAAS,CAKV,CACZ,CAAC,IACD,CACJ,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAS,eAAe,CAAC,IAAe,EAAE,eAAwB;IAChE,IAAI,eAAe,EAAE,CAAC;QACpB,OAAO,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC;IACjD,CAAC;IACD,OAAO,IAAI,CAAC,GAAG,CAAC;AAClB,CAAC"}
1
+ {"version":3,"file":"ScenarioComposition.js","sourceRoot":"","sources":["../src/ScenarioComposition.tsx"],"names":[],"mappings":"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"}
package/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- export type { ScenarioBundle, NarrationManifest, ScenarioStep, StepTimeline } from "@scenar/core";
1
+ export type { ScenarioBundle, NarrationManifest, ScenarioStep, StepTimeline, TitleCard, TitleCards, StepCard, AppliedTitleCards, } from "@scenar/core";
2
+ export { applyTitleCards } from "@scenar/core";
2
3
  export { ScenarioComposition } from "./ScenarioComposition.js";
3
4
  export type { ScenarioTimeline, AudioClip } from "./useScenarioTimeline.js";
4
- export { useScenarioTimeline, calculateScenarioTimeline, } from "./useScenarioTimeline.js";
5
+ export { useScenarioTimeline, calculateScenarioTimeline, msToFrames, } from "./useScenarioTimeline.js";
5
6
  //# sourceMappingURL=index.d.ts.map
package/index.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,YAAY,EAAE,cAAc,EAAE,iBAAiB,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAGlG,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAG/D,YAAY,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC5E,OAAO,EACL,mBAAmB,EACnB,yBAAyB,GAC1B,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,UAAU,EACV,QAAQ,EACR,iBAAiB,GAClB,MAAM,cAAc,CAAC;AAItB,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAG/C,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAG/D,YAAY,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC5E,OAAO,EACL,mBAAmB,EACnB,yBAAyB,EACzB,UAAU,GACX,MAAM,0BAA0B,CAAC"}
package/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  // @scenar/remotion — public API
2
2
  // Remotion integration for rendering Scenar scenarios as videos.
3
+ // Card synthesis is re-exported so the generated render entry — which
4
+ // already imports this package — never needs @scenar/core as a direct
5
+ // dependency of the scenario project.
6
+ export { applyTitleCards } from "@scenar/core";
3
7
  // Composition
4
8
  export { ScenarioComposition } from "./ScenarioComposition.js";
5
- export { useScenarioTimeline, calculateScenarioTimeline, } from "./useScenarioTimeline.js";
9
+ export { useScenarioTimeline, calculateScenarioTimeline, msToFrames, } from "./useScenarioTimeline.js";
6
10
  //# sourceMappingURL=index.js.map
package/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,iEAAiE;AAKjE,cAAc;AACd,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAI/D,OAAO,EACL,mBAAmB,EACnB,yBAAyB,GAC1B,MAAM,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,iEAAiE;AAajE,sEAAsE;AACtE,sEAAsE;AACtE,sCAAsC;AACtC,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C,cAAc;AACd,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAI/D,OAAO,EACL,mBAAmB,EACnB,yBAAyB,EACzB,UAAU,GACX,MAAM,0BAA0B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scenar/remotion",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Remotion integration for Scenar — render scenario demos as pixel-perfect MP4 videos.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  }
30
30
  },
31
31
  "dependencies": {
32
- "@scenar/core": "0.10.0",
33
- "@scenar/react": "0.10.0"
32
+ "@scenar/core": "0.11.0",
33
+ "@scenar/react": "0.11.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@remotion/renderer": "4.0.448",
@@ -1,17 +1,46 @@
1
- import { type ReactNode } from "react";
1
+ import { type ReactNode, useMemo } from "react";
2
2
  import {
3
3
  Audio,
4
+ OffthreadVideo,
4
5
  Sequence,
5
6
  staticFile,
6
7
  useCurrentFrame,
7
8
  useVideoConfig,
8
9
  } from "remotion";
9
- import type { ScenarioBundle } from "@scenar/core";
10
- import { TimeSourceProvider, VideoExportProvider, ScenarioPlayer } from "@scenar/react";
11
- import { useScenarioTimeline, type AudioClip } from "./useScenarioTimeline.js";
10
+ import {
11
+ type ScenarioBundle,
12
+ type SfxSound,
13
+ computeMusicEnvelope,
14
+ deriveSfxTimeline,
15
+ musicGainAt,
16
+ } from "@scenar/core";
17
+ import {
18
+ type PresenterMediaRenderer,
19
+ TimeSourceProvider,
20
+ VideoExportProvider,
21
+ ScenarioPlayer,
22
+ } from "@scenar/react";
23
+ import { msToFrames, useScenarioTimeline } from "./useScenarioTimeline.js";
12
24
 
13
25
  const DEFAULT_FPS = 30;
14
26
 
27
+ /**
28
+ * Frame window an SFX `<Sequence>` stays mounted. Comfortably longer than
29
+ * the ~35 ms synthesized ticks (plus MP3 encoder padding), short enough
30
+ * that rapid typing never accumulates hundreds of live audio tags.
31
+ */
32
+ const SFX_WINDOW_MS = 500;
33
+
34
+ /**
35
+ * Default staged locations of the built-in SFX set, mirroring where
36
+ * `scenar render` stages `@scenar/react`'s assets in the public dir.
37
+ * Resolved through the same `staticFile` path as every other audio src.
38
+ */
39
+ const DEFAULT_SFX_SRCS: Record<SfxSound, string> = {
40
+ click: "soundtrack/sfx/click.mp3",
41
+ keystroke: "soundtrack/sfx/keystroke.mp3",
42
+ };
43
+
15
44
  interface ScenarioCompositionProps<T> {
16
45
  /** Self-contained scenario bundle with steps and optional narration. */
17
46
  bundle: ScenarioBundle<T>;
@@ -26,6 +55,20 @@ interface ScenarioCompositionProps<T> {
26
55
  * Defaults to true.
27
56
  */
28
57
  useStaticFile?: boolean;
58
+ /**
59
+ * Burn step captions into the video: each step's `narration` text
60
+ * renders as a subtitle-style overlay. Forwarded to `ScenarioPlayer` —
61
+ * the same overlay the interactive embed shows, so both outputs caption
62
+ * identically. Defaults to false.
63
+ */
64
+ captions?: boolean;
65
+ /**
66
+ * Asset paths for the built-in SFX set when `bundle.soundtrack.sfx` is
67
+ * enabled, resolved like every other audio src (through `staticFile`
68
+ * when `useStaticFile` is on). Defaults to the locations `scenar
69
+ * render` stages them at (`soundtrack/sfx/<name>.mp3`).
70
+ */
71
+ sfxSrcs?: Record<SfxSound, string>;
29
72
  }
30
73
 
31
74
  /**
@@ -75,6 +118,8 @@ export function ScenarioComposition<T>({
75
118
  children,
76
119
  fps: fpsProp,
77
120
  useStaticFile: useStaticFileProp = true,
121
+ captions = false,
122
+ sfxSrcs = DEFAULT_SFX_SRCS,
78
123
  }: ScenarioCompositionProps<T>) {
79
124
  const videoConfig = useVideoConfig();
80
125
  const fps = fpsProp ?? videoConfig.fps ?? DEFAULT_FPS;
@@ -88,14 +133,97 @@ export function ScenarioComposition<T>({
88
133
 
89
134
  const currentTimeMs = (frame / fps) * 1000;
90
135
 
136
+ // Synthesized card steps may carry a logo asset. Like every audio src,
137
+ // the authored path (e.g. "./logo.png") points at a file `scenar render`
138
+ // stages into the public dir, so it resolves through the same
139
+ // staticFile path before the player renders it.
140
+ const playerBundle = useMemo(() => {
141
+ if (!bundle.steps.some((step) => step.card?.logoSrc)) return bundle;
142
+ return {
143
+ ...bundle,
144
+ steps: bundle.steps.map((step) =>
145
+ step.card?.logoSrc
146
+ ? {
147
+ ...step,
148
+ card: {
149
+ ...step.card,
150
+ logoSrc: resolveAssetSrc(step.card.logoSrc, useStaticFileProp),
151
+ },
152
+ }
153
+ : step,
154
+ ),
155
+ };
156
+ }, [bundle, useStaticFileProp]);
157
+
158
+ const soundtrack = bundle.soundtrack;
159
+
160
+ // Music level per frame — the same pure @scenar/core envelope the
161
+ // browser player automates through its gain node, evaluated here as a
162
+ // Remotion volume function so both outputs duck identically.
163
+ const musicEnvelope = useMemo(
164
+ () =>
165
+ soundtrack?.musicSrc
166
+ ? computeMusicEnvelope(bundle.steps, bundle.narrationManifest, soundtrack)
167
+ : null,
168
+ [soundtrack, bundle.steps, bundle.narrationManifest],
169
+ );
170
+ const musicVolume = useMemo(
171
+ () =>
172
+ musicEnvelope
173
+ ? (f: number) => musicGainAt(musicEnvelope, (f / fps) * 1000)
174
+ : undefined,
175
+ [musicEnvelope, fps],
176
+ );
177
+
178
+ // The presenter media slot for the export time domain (T02 decision
179
+ // G2-5): the player owns the PiP frame and its fade; this renderer
180
+ // fills the frame with a frame-locked clip. `layout="none"` nests the
181
+ // Sequence inside the player's DOM without an absolute-fill wrapper;
182
+ // `from` is composition-absolute because the player tree sits outside
183
+ // any other Sequence. Muted — narration remains the only audio.
184
+ const presenterMedia = useMemo<PresenterMediaRenderer | undefined>(() => {
185
+ if (!bundle.presenterManifest) return undefined;
186
+ const renderer: PresenterMediaRenderer = ({ src, window: clipWindow }) => (
187
+ <Sequence
188
+ layout="none"
189
+ from={msToFrames(clipWindow.startMs, fps)}
190
+ durationInFrames={Math.max(1, msToFrames(clipWindow.clipDurationMs, fps))}
191
+ >
192
+ <OffthreadVideo
193
+ muted
194
+ src={resolveAssetSrc(src, useStaticFileProp)}
195
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
196
+ />
197
+ </Sequence>
198
+ );
199
+ return renderer;
200
+ }, [bundle.presenterManifest, fps, useStaticFileProp]);
201
+
202
+ // Derived SFX events converted from step-relative offsets to absolute
203
+ // frames via the shared step starts — the export-side consumer of the
204
+ // same `deriveSfxTimeline` the browser scheduler plays.
205
+ const sfxClips = useMemo(() => {
206
+ if (soundtrack?.sfx !== true) return [];
207
+ return deriveSfxTimeline(bundle.steps, bundle.narrationManifest).map(
208
+ (event, index) => ({
209
+ key: `sfx-${index}`,
210
+ startFrame: msToFrames(
211
+ (timeline.stepStartTimesMs[event.stepIndex] ?? 0) + event.offsetMs,
212
+ fps,
213
+ ),
214
+ src: sfxSrcs[event.sound],
215
+ }),
216
+ );
217
+ }, [soundtrack, bundle.steps, bundle.narrationManifest, timeline, fps, sfxSrcs]);
218
+
91
219
  return (
92
220
  <>
93
221
  <TimeSourceProvider
94
222
  currentTimeMs={currentTimeMs}
95
223
  stepStartTimesMs={timeline.stepStartTimesMs}
96
224
  >
97
- <VideoExportProvider>
98
- <ScenarioPlayer bundle={bundle}>
225
+ <VideoExportProvider presenterMedia={presenterMedia}>
226
+ <ScenarioPlayer bundle={playerBundle} captions={captions}>
99
227
  {children}
100
228
  </ScenarioPlayer>
101
229
  </VideoExportProvider>
@@ -107,7 +235,28 @@ export function ScenarioComposition<T>({
107
235
  from={clip.startFrame}
108
236
  durationInFrames={clip.durationFrames}
109
237
  >
110
- <Audio src={resolveAudioSrc(clip, useStaticFileProp)} />
238
+ <Audio src={resolveAssetSrc(clip.src, useStaticFileProp)} />
239
+ </Sequence>
240
+ ))}
241
+
242
+ {soundtrack?.musicSrc && (
243
+ // The music spans the whole composition: `loop` folds a shorter
244
+ // asset seamlessly; the volume function carries fade-in, ducking,
245
+ // and the closing fade-out, so no trim math is needed here.
246
+ <Audio
247
+ loop
248
+ src={resolveAssetSrc(soundtrack.musicSrc, useStaticFileProp)}
249
+ volume={musicVolume}
250
+ />
251
+ )}
252
+
253
+ {sfxClips.map((clip) => (
254
+ <Sequence
255
+ key={clip.key}
256
+ from={clip.startFrame}
257
+ durationInFrames={msToFrames(SFX_WINDOW_MS, fps)}
258
+ >
259
+ <Audio src={resolveAssetSrc(clip.src, useStaticFileProp)} />
111
260
  </Sequence>
112
261
  ))}
113
262
  </>
@@ -115,13 +264,15 @@ export function ScenarioComposition<T>({
115
264
  }
116
265
 
117
266
  /**
118
- * Resolve audio source URL. When `useStaticFile` is true, paths are
119
- * resolved through Remotion's `staticFile()` (strips leading `/`).
120
- * This matches Stigmer's pattern: `staticFile(clip.src.replace(/^\//, ""))`.
267
+ * Resolve a staged asset URL (audio clips, card logos). When
268
+ * `useStaticFile` is true, paths are resolved through Remotion's
269
+ * `staticFile()`, stripping a leading `/` (Stigmer's original pattern)
270
+ * or `./` (the form `scenar narrate`, soundtrack, and title-card
271
+ * configs write) so the path lands inside the staged public dir.
121
272
  */
122
- function resolveAudioSrc(clip: AudioClip, useStaticFileFn: boolean): string {
273
+ function resolveAssetSrc(src: string, useStaticFileFn: boolean): string {
123
274
  if (useStaticFileFn) {
124
- return staticFile(clip.src.replace(/^\//, ""));
275
+ return staticFile(src.replace(/^(\.\/|\/)/, ""));
125
276
  }
126
- return clip.src;
277
+ return src;
127
278
  }
@@ -1,13 +1,30 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { beforeEach, describe, it, expect, vi } from "vitest";
2
2
  import { render } from "@testing-library/react";
3
3
 
4
+ /** Props of every rendered Remotion `Audio`, in render order — lets tests
5
+ * inspect the `volume` function and `loop` flag the DOM cannot carry.
6
+ * `frame` drives the mocked `useCurrentFrame` so tests can position the
7
+ * composition mid-timeline. */
8
+ const captured = vi.hoisted(() => ({
9
+ audios: [] as Array<{ src: string; loop?: boolean; volume?: (f: number) => number }>,
10
+ frame: 0,
11
+ }));
12
+
4
13
  vi.mock("remotion", () => ({
5
- useCurrentFrame: () => 0,
14
+ useCurrentFrame: () => captured.frame,
6
15
  useVideoConfig: () => ({ fps: 30, width: 1920, height: 1080, durationInFrames: 300 }),
7
16
  AbsoluteFill: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
8
- Audio: ({ src }: { src: string }) => (
9
- <audio data-testid="remotion-audio" data-remotion-audio="true" src={src} />
10
- ),
17
+ Audio: (props: { src: string; loop?: boolean; volume?: (f: number) => number }) => {
18
+ captured.audios.push(props);
19
+ return (
20
+ <audio
21
+ data-testid="remotion-audio"
22
+ data-remotion-audio="true"
23
+ data-loop={props.loop ? "true" : undefined}
24
+ src={props.src}
25
+ />
26
+ );
27
+ },
11
28
  Sequence: ({
12
29
  from,
13
30
  durationInFrames,
@@ -25,12 +42,30 @@ vi.mock("remotion", () => ({
25
42
  {children}
26
43
  </div>
27
44
  ),
45
+ OffthreadVideo: (props: { src: string; muted?: boolean }) => (
46
+ <div
47
+ data-testid="remotion-offthread-video"
48
+ data-muted={props.muted ? "true" : undefined}
49
+ data-src={props.src}
50
+ />
51
+ ),
28
52
  staticFile: (path: string) => `/static/${path}`,
29
53
  }));
30
54
 
31
- import type { ScenarioBundle, NarrationManifest } from "@scenar/core";
55
+ import {
56
+ type NarrationManifest,
57
+ type ScenarioBundle,
58
+ CLICK_DELAY_MS,
59
+ computeMusicEnvelope,
60
+ musicGainAt,
61
+ } from "@scenar/core";
32
62
  import { ScenarioComposition } from "../ScenarioComposition.js";
33
63
 
64
+ beforeEach(() => {
65
+ captured.audios = [];
66
+ captured.frame = 0;
67
+ });
68
+
34
69
  type StepData = { view: string };
35
70
 
36
71
  const bundle: ScenarioBundle<StepData> = {
@@ -126,3 +161,209 @@ describe("ScenarioComposition", () => {
126
161
  expect(remotionAudios[1]!.getAttribute("src")).toBe("/audio/step-2.mp3");
127
162
  });
128
163
  });
164
+
165
+ // --- Soundtrack ---
166
+
167
+ const bundleWithMusic: ScenarioBundle<StepData> = {
168
+ ...bundleWithNarration,
169
+ soundtrack: { musicSrc: "./soundtrack/music.mp3", musicVolume: 0.5, duckingVolume: 0.1 },
170
+ };
171
+
172
+ const bundleWithSfx: ScenarioBundle<StepData> = {
173
+ ...bundle,
174
+ soundtrack: { sfx: true },
175
+ steps: [
176
+ {
177
+ delayMs: 0,
178
+ data: { view: "intro" },
179
+ interactions: [{ atPercent: 0, type: "click", target: "btn" }],
180
+ },
181
+ { delayMs: 2000, data: { view: "body" } },
182
+ ],
183
+ };
184
+
185
+ describe("ScenarioComposition soundtrack", () => {
186
+ it("renders a looping music track resolved through staticFile", () => {
187
+ render(
188
+ <ScenarioComposition bundle={bundleWithMusic}>
189
+ {(data: StepData) => <div>{data.view}</div>}
190
+ </ScenarioComposition>,
191
+ );
192
+
193
+ const music = captured.audios.find((a) => a.loop);
194
+ expect(music).toBeDefined();
195
+ // "./soundtrack/music.mp3" strips its leading "./" before staticFile.
196
+ expect(music!.src).toBe("/static/soundtrack/music.mp3");
197
+ expect(typeof music!.volume).toBe("function");
198
+ });
199
+
200
+ it("drives the music volume from the shared core envelope (parity)", () => {
201
+ render(
202
+ <ScenarioComposition bundle={bundleWithMusic}>
203
+ {(data: StepData) => <div>{data.view}</div>}
204
+ </ScenarioComposition>,
205
+ );
206
+
207
+ const volume = captured.audios.find((a) => a.loop)!.volume!;
208
+ const envelope = computeMusicEnvelope(
209
+ bundleWithMusic.steps,
210
+ bundleWithMusic.narrationManifest,
211
+ bundleWithMusic.soundtrack!,
212
+ );
213
+ // Sample across fade-in, ducked narration, and the plateau: the frame
214
+ // function must agree with the pure envelope at the frame's time.
215
+ for (const frame of [0, 15, 30, 90, 100, 135, 150]) {
216
+ expect(volume(frame)).toBe(musicGainAt(envelope, (frame / 30) * 1000));
217
+ }
218
+ });
219
+
220
+ it("is deterministic: two renders produce identical volume samples", () => {
221
+ render(
222
+ <ScenarioComposition bundle={bundleWithMusic}>
223
+ {(data: StepData) => <div>{data.view}</div>}
224
+ </ScenarioComposition>,
225
+ );
226
+ const first = captured.audios.find((a) => a.loop)!.volume!;
227
+ const firstSamples = [0, 10, 50, 120, 149].map((f) => first(f));
228
+
229
+ captured.audios = [];
230
+ render(
231
+ <ScenarioComposition bundle={bundleWithMusic}>
232
+ {(data: StepData) => <div>{data.view}</div>}
233
+ </ScenarioComposition>,
234
+ );
235
+ const second = captured.audios.find((a) => a.loop)!.volume!;
236
+ expect([0, 10, 50, 120, 149].map((f) => second(f))).toEqual(firstSamples);
237
+ });
238
+
239
+ it("places SFX sequences at the derived frame offsets", () => {
240
+ const { container } = render(
241
+ <ScenarioComposition bundle={bundleWithSfx}>
242
+ {(data: StepData) => <div>{data.view}</div>}
243
+ </ScenarioComposition>,
244
+ );
245
+
246
+ // One click at atPercent 0 in a 2000ms step: dispatch at 450ms
247
+ // (CLICK_DELAY_MS) → frame round(450 * 30 / 1000) = 14.
248
+ const sequences = container.querySelectorAll("[data-testid='remotion-sequence']");
249
+ expect(sequences).toHaveLength(1);
250
+ expect(sequences[0]!.getAttribute("data-from")).toBe(
251
+ String(Math.round((CLICK_DELAY_MS * 30) / 1000)),
252
+ );
253
+
254
+ const sfxAudio = captured.audios.find((a) => a.src.includes("sfx"));
255
+ expect(sfxAudio!.src).toBe("/static/soundtrack/sfx/click.mp3");
256
+ });
257
+
258
+ it("renders no soundtrack audio when the bundle has none", () => {
259
+ render(
260
+ <ScenarioComposition bundle={bundleWithNarration}>
261
+ {(data: StepData) => <div>{data.view}</div>}
262
+ </ScenarioComposition>,
263
+ );
264
+ expect(captured.audios.some((a) => a.loop)).toBe(false);
265
+ expect(captured.audios.some((a) => a.src.includes("sfx"))).toBe(false);
266
+ });
267
+ });
268
+
269
+ // --- Presenter track ---
270
+
271
+ // Clip on step 0 (2000ms) — active at frame 0. Duration mirrors the
272
+ // narration entry, as `scenar presenter` writes it.
273
+ const bundleWithPresenter: ScenarioBundle<StepData> = {
274
+ ...bundle,
275
+ narrationManifest: {
276
+ steps: [{ src: "/audio/step-0.mp3", durationMs: 2000 }, null, null],
277
+ },
278
+ presenterManifest: {
279
+ steps: [{ src: "./step-0.mp4", durationMs: 2000 }, null, null],
280
+ },
281
+ };
282
+
283
+ function renderPresenterComposition() {
284
+ return render(
285
+ <ScenarioComposition bundle={bundleWithPresenter}>
286
+ {(data: StepData) => <div>{data.view}</div>}
287
+ </ScenarioComposition>,
288
+ );
289
+ }
290
+
291
+ describe("ScenarioComposition presenter", () => {
292
+ it("fills the presenter frame with a muted frame-locked clip via staticFile", () => {
293
+ const { container } = renderPresenterComposition();
294
+
295
+ const media = container.querySelector("[data-testid='remotion-offthread-video']")!;
296
+ expect(media).not.toBeNull();
297
+ expect(media.getAttribute("data-muted")).toBe("true");
298
+ // "./step-0.mp4" strips its leading "./" before staticFile.
299
+ expect(media.getAttribute("data-src")).toBe("/static/step-0.mp4");
300
+ // The export path renders no browser <video>.
301
+ expect(container.querySelector("video")).toBeNull();
302
+ });
303
+
304
+ it("places the clip Sequence at the window's frame offsets", () => {
305
+ const { container } = renderPresenterComposition();
306
+
307
+ const frame = container.querySelector("[data-scenar-presenter]")!;
308
+ const sequence = frame.querySelector("[data-testid='remotion-sequence']")!;
309
+ // Window: step 0 starts at 0ms, clip 2000ms → 60 frames at 30fps.
310
+ expect(sequence.getAttribute("data-from")).toBe("0");
311
+ expect(sequence.getAttribute("data-duration")).toBe("60");
312
+ });
313
+
314
+ it("renders the fade from frame time through the shared pure function", () => {
315
+ // Frame 30 = 1000ms: mid-clip, fully opaque.
316
+ captured.frame = 30;
317
+ const mid = renderPresenterComposition();
318
+ const midFrame = mid.container.querySelector("[data-scenar-presenter]") as HTMLElement;
319
+ expect(Number(midFrame.style.opacity)).toBe(1);
320
+ mid.unmount();
321
+
322
+ // Frame 3 = 100ms: half-way through the 200ms fade-in.
323
+ captured.frame = 3;
324
+ const fading = renderPresenterComposition();
325
+ const fadingFrame = fading.container.querySelector(
326
+ "[data-scenar-presenter]",
327
+ ) as HTMLElement;
328
+ expect(Number(fadingFrame.style.opacity)).toBeCloseTo(0.5, 5);
329
+ });
330
+
331
+ it("is deterministic: the same bundle at the same frame renders identical presenter state, twice", () => {
332
+ captured.frame = 30;
333
+
334
+ const first = renderPresenterComposition();
335
+ const firstFrame = first.container.querySelector("[data-scenar-presenter]") as HTMLElement;
336
+ const firstState = {
337
+ opacity: firstFrame.style.opacity,
338
+ src: first.container
339
+ .querySelector("[data-testid='remotion-offthread-video']")!
340
+ .getAttribute("data-src"),
341
+ from: firstFrame
342
+ .querySelector("[data-testid='remotion-sequence']")!
343
+ .getAttribute("data-from"),
344
+ };
345
+ first.unmount();
346
+
347
+ const second = renderPresenterComposition();
348
+ const secondFrame = second.container.querySelector("[data-scenar-presenter]") as HTMLElement;
349
+ expect({
350
+ opacity: secondFrame.style.opacity,
351
+ src: second.container
352
+ .querySelector("[data-testid='remotion-offthread-video']")!
353
+ .getAttribute("data-src"),
354
+ from: secondFrame
355
+ .querySelector("[data-testid='remotion-sequence']")!
356
+ .getAttribute("data-from"),
357
+ }).toEqual(firstState);
358
+ });
359
+
360
+ it("renders no presenter DOM when the bundle has no presenter manifest", () => {
361
+ const { container } = render(
362
+ <ScenarioComposition bundle={bundleWithNarration}>
363
+ {(data: StepData) => <div>{data.view}</div>}
364
+ </ScenarioComposition>,
365
+ );
366
+ expect(container.querySelector("[data-scenar-presenter]")).toBeNull();
367
+ expect(container.querySelector("[data-testid='remotion-offthread-video']")).toBeNull();
368
+ });
369
+ });
package/src/index.ts CHANGED
@@ -2,7 +2,20 @@
2
2
  // Remotion integration for rendering Scenar scenarios as videos.
3
3
 
4
4
  // Re-export core types for consumer convenience
5
- export type { ScenarioBundle, NarrationManifest, ScenarioStep, StepTimeline } from "@scenar/core";
5
+ export type {
6
+ ScenarioBundle,
7
+ NarrationManifest,
8
+ ScenarioStep,
9
+ StepTimeline,
10
+ TitleCard,
11
+ TitleCards,
12
+ StepCard,
13
+ AppliedTitleCards,
14
+ } from "@scenar/core";
15
+ // Card synthesis is re-exported so the generated render entry — which
16
+ // already imports this package — never needs @scenar/core as a direct
17
+ // dependency of the scenario project.
18
+ export { applyTitleCards } from "@scenar/core";
6
19
 
7
20
  // Composition
8
21
  export { ScenarioComposition } from "./ScenarioComposition.js";
@@ -12,4 +25,5 @@ export type { ScenarioTimeline, AudioClip } from "./useScenarioTimeline.js";
12
25
  export {
13
26
  useScenarioTimeline,
14
27
  calculateScenarioTimeline,
28
+ msToFrames,
15
29
  } from "./useScenarioTimeline.js";
@@ -28,7 +28,12 @@ export interface ScenarioTimeline extends StepTimeline {
28
28
  fps: number;
29
29
  }
30
30
 
31
- function msToFrames(ms: number, fps: number): number {
31
+ /**
32
+ * THE ms → frame conversion for the export path — every frame offset in
33
+ * the composition (steps, narration, soundtrack SFX) goes through this
34
+ * one rounding so nothing can drift by a frame against anything else.
35
+ */
36
+ export function msToFrames(ms: number, fps: number): number {
32
37
  return Math.round((ms * fps) / 1000);
33
38
  }
34
39
 
@@ -19,6 +19,12 @@ export interface ScenarioTimeline extends StepTimeline {
19
19
  /** Frames per second used for the conversion. */
20
20
  fps: number;
21
21
  }
22
+ /**
23
+ * THE ms → frame conversion for the export path — every frame offset in
24
+ * the composition (steps, narration, soundtrack SFX) goes through this
25
+ * one rounding so nothing can drift by a frame against anything else.
26
+ */
27
+ export declare function msToFrames(ms: number, fps: number): number;
22
28
  /**
23
29
  * Convert Scenar's millisecond-based step timeline into Remotion frame
24
30
  * counts. The returned values are stable across renders for the same
@@ -1 +1 @@
1
- {"version":3,"file":"useScenarioTimeline.d.ts","sourceRoot":"","sources":["../src/useScenarioTimeline.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,YAAY,EAElB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,6CAA6C;IAC7C,UAAU,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,gEAAgE;IAChE,gBAAgB,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,mEAAmE;IACnE,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,iDAAiD;IACjD,GAAG,EAAE,MAAM,CAAC;CACb;AAmDD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,SAAS,YAAY,CAAC,OAAO,CAAC,EAAE,EACvC,iBAAiB,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,EACvD,GAAG,EAAE,MAAM,GACV,gBAAgB,CAKlB;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,SAAS,YAAY,CAAC,OAAO,CAAC,EAAE,EACvC,iBAAiB,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,EACvD,GAAG,EAAE,MAAM,GACV,gBAAgB,CAElB"}
1
+ {"version":3,"file":"useScenarioTimeline.d.ts","sourceRoot":"","sources":["../src/useScenarioTimeline.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,YAAY,EAElB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,6CAA6C;IAC7C,UAAU,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,gEAAgE;IAChE,gBAAgB,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B,mEAAmE;IACnE,UAAU,EAAE,SAAS,EAAE,CAAC;IACxB,iDAAiD;IACjD,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAE1D;AA+CD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,SAAS,YAAY,CAAC,OAAO,CAAC,EAAE,EACvC,iBAAiB,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,EACvD,GAAG,EAAE,MAAM,GACV,gBAAgB,CAKlB;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,SAAS,YAAY,CAAC,OAAO,CAAC,EAAE,EACvC,iBAAiB,EAAE,iBAAiB,GAAG,IAAI,GAAG,SAAS,EACvD,GAAG,EAAE,MAAM,GACV,gBAAgB,CAElB"}
@@ -1,6 +1,11 @@
1
1
  import { useMemo } from "react";
2
2
  import { computeStepTimeline, } from "@scenar/core";
3
- function msToFrames(ms, fps) {
3
+ /**
4
+ * THE ms → frame conversion for the export path — every frame offset in
5
+ * the composition (steps, narration, soundtrack SFX) goes through this
6
+ * one rounding so nothing can drift by a frame against anything else.
7
+ */
8
+ export function msToFrames(ms, fps) {
4
9
  return Math.round((ms * fps) / 1000);
5
10
  }
6
11
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"useScenarioTimeline.js","sourceRoot":"","sources":["../src/useScenarioTimeline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AAChC,OAAO,EAIL,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAwBtB,SAAS,UAAU,CAAC,EAAU,EAAE,GAAW;IACzC,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;AACvC,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,aAAa,CACpB,KAAuC,EACvC,iBAAuD,EACvD,GAAW;IAEX,MAAM,EAAE,gBAAgB,EAAE,eAAe,EAAE,GAAG,mBAAmB,CAC/D,KAAK,EACL,iBAAiB,CAClB,CAAC;IAEF,MAAM,eAAe,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC,CAAC;IAC1E,MAAM,gBAAgB,GAAG,UAAU,CAAC,eAAe,EAAE,GAAG,CAAC,CAAC;IAE1D,MAAM,UAAU,GAAgB,EAAE,CAAC;IACnC,IAAI,iBAAiB,EAAE,CAAC;QACtB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,iBAAiB,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACxD,MAAM,KAAK,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YACzC,IAAI,CAAC,KAAK;gBAAE,SAAS;YACrB,UAAU,CAAC,IAAI,CAAC;gBACd,SAAS,EAAE,CAAC;gBACZ,GAAG,EAAE,KAAK,CAAC,GAAG;gBACd,UAAU,EAAE,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC;gBACnC,cAAc,EAAE,UAAU,CAAC,KAAK,CAAC,UAAU,EAAE,GAAG,CAAC;aAClD,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,OAAO;QACL,gBAAgB;QAChB,eAAe;QACf,UAAU;QACV,eAAe;QACf,gBAAgB;QAChB,GAAG;KACJ,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAuC,EACvC,iBAAuD,EACvD,GAAW;IAEX,OAAO,OAAO,CACZ,GAAG,EAAE,CAAC,aAAa,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,CAAC,EAClD,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,CAAC,CAChC,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CACvC,KAAuC,EACvC,iBAAuD,EACvD,GAAW;IAEX,OAAO,aAAa,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,CAAC,CAAC;AACtD,CAAC"}
1
+ {"version":3,"file":"useScenarioTimeline.js","sourceRoot":"","sources":["../src/useScenarioTimeline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AAChC,OAAO,EAIL,mBAAmB,GACpB,MAAM,cAAc,CAAC;AAwBtB;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,EAAU,EAAE,GAAW;IAChD,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC;AACvC,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,aAAa,CACpB,KAAuC,EACvC,iBAAuD,EACvD,GAAW;IAEX,MAAM,EAAE,gBAAgB,EAAE,eAAe,EAAE,GAAG,mBAAmB,CAC/D,KAAK,EACL,iBAAiB,CAClB,CAAC;IAEF,MAAM,eAAe,GAAG,gBAAgB,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC,CAAC;IAC1E,MAAM,gBAAgB,GAAG,UAAU,CAAC,eAAe,EAAE,GAAG,CAAC,CAAC;IAE1D,MAAM,UAAU,GAAgB,EAAE,CAAC;IACnC,IAAI,iBAAiB,EAAE,CAAC;QACtB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,iBAAiB,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YACxD,MAAM,KAAK,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;YACzC,IAAI,CAAC,KAAK;gBAAE,SAAS;YACrB,UAAU,CAAC,IAAI,CAAC;gBACd,SAAS,EAAE,CAAC;gBACZ,GAAG,EAAE,KAAK,CAAC,GAAG;gBACd,UAAU,EAAE,eAAe,CAAC,CAAC,CAAC,IAAI,CAAC;gBACnC,cAAc,EAAE,UAAU,CAAC,KAAK,CAAC,UAAU,EAAE,GAAG,CAAC;aAClD,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,OAAO;QACL,gBAAgB;QAChB,eAAe;QACf,UAAU;QACV,eAAe;QACf,gBAAgB;QAChB,GAAG;KACJ,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAuC,EACvC,iBAAuD,EACvD,GAAW;IAEX,OAAO,OAAO,CACZ,GAAG,EAAE,CAAC,aAAa,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,CAAC,EAClD,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,CAAC,CAChC,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CACvC,KAAuC,EACvC,iBAAuD,EACvD,GAAW;IAEX,OAAO,aAAa,CAAC,KAAK,EAAE,iBAAiB,EAAE,GAAG,CAAC,CAAC;AACtD,CAAC"}