@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.
- package/ScenarioComposition.d.ts +16 -2
- package/ScenarioComposition.d.ts.map +1 -1
- package/ScenarioComposition.js +88 -11
- package/ScenarioComposition.js.map +1 -1
- package/index.d.ts +3 -2
- package/index.d.ts.map +1 -1
- package/index.js +5 -1
- package/index.js.map +1 -1
- package/package.json +3 -3
- package/src/ScenarioComposition.tsx +164 -13
- package/src/__tests__/ScenarioComposition.test.tsx +247 -6
- package/src/index.ts +15 -1
- package/src/useScenarioTimeline.ts +6 -1
- package/useScenarioTimeline.d.ts +6 -0
- package/useScenarioTimeline.d.ts.map +1 -1
- package/useScenarioTimeline.js +6 -1
- package/useScenarioTimeline.js.map +1 -1
package/ScenarioComposition.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
-
import type
|
|
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,
|
|
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"}
|
package/ScenarioComposition.js
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
|
58
|
-
* resolved through Remotion's
|
|
59
|
-
*
|
|
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
|
|
138
|
+
function resolveAssetSrc(src, useStaticFileFn) {
|
|
62
139
|
if (useStaticFileFn) {
|
|
63
|
-
return staticFile(
|
|
140
|
+
return staticFile(src.replace(/^(\.\/|\/)/, ""));
|
|
64
141
|
}
|
|
65
|
-
return
|
|
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":";
|
|
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,
|
|
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;
|
|
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.
|
|
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.
|
|
33
|
-
"@scenar/react": "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
|
|
10
|
-
|
|
11
|
-
|
|
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={
|
|
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={
|
|
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
|
|
119
|
-
* resolved through Remotion's
|
|
120
|
-
*
|
|
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
|
|
273
|
+
function resolveAssetSrc(src: string, useStaticFileFn: boolean): string {
|
|
123
274
|
if (useStaticFileFn) {
|
|
124
|
-
return staticFile(
|
|
275
|
+
return staticFile(src.replace(/^(\.\/|\/)/, ""));
|
|
125
276
|
}
|
|
126
|
-
return
|
|
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: () =>
|
|
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: (
|
|
9
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
package/useScenarioTimeline.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/useScenarioTimeline.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { computeStepTimeline, } from "@scenar/core";
|
|
3
|
-
|
|
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,
|
|
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"}
|