@ossclip/scenes 0.1.33 → 0.1.35

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/scenes",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "ossclip's scene library and stage geometry — React components shared by preview and render",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@
19
19
  ],
20
20
  "dependencies": {
21
21
  "zod": "^3.25.0",
22
- "@ossclip/core": "0.1.33"
22
+ "@ossclip/core": "0.1.35"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "react": ">=18",
@@ -0,0 +1,50 @@
1
+ import React from "react";
2
+ import { Audio, Sequence, staticFile, useVideoConfig } from "remotion";
3
+ import { visibleSfxCues, type SfxCueProps } from "./sfx-track";
4
+
5
+ /**
6
+ * The `--sfx` sound-effect track: one `<Sequence>` per cue, each mounting an
7
+ * `<Audio>` at its own instant.
8
+ *
9
+ * MIXED INSIDE REMOTION ON PURPOSE, and this is the whole reason the feature
10
+ * is cheap: the render's audio goes through loudnorm afterwards (core's
11
+ * ingest.ts), so a mix done here is MEASURED as part of the programme — the
12
+ * effects sit at a level normalized against the speech instead of being
13
+ * stamped on after the loudness pass, which is what a post-render ffmpeg
14
+ * overlay would have meant (and would have needed its own gain staging to
15
+ * avoid clipping the take it lands on).
16
+ *
17
+ * No duration on the Sequences: an effect plays for its own length. That is
18
+ * also why cues can overlap the composition's own end — Remotion truncates the
19
+ * tail at the last frame, and a whoosh clipped by the end of the video is the
20
+ * same thing an editor would do by hand.
21
+ *
22
+ * Deliberately invisible to the editor, the Watermark/CoverInVideo rule: no
23
+ * `data-edit-id`, nothing to hit-test, nothing rendered at all. Placement
24
+ * editing arrives through the overrides doc, not through the stage.
25
+ */
26
+ export const SfxTrack: React.FC<{ cues: readonly SfxCueProps[] }> = ({ cues }) => {
27
+ const { fps, durationInFrames } = useVideoConfig();
28
+ return (
29
+ <>
30
+ {visibleSfxCues(cues, fps, durationInFrames).map((cue, i) => (
31
+ <Sequence
32
+ // Index in the VISIBLE list plus the frame: two effects can share a
33
+ // frame (different sounds, same beat), so neither alone is a stable
34
+ // key, and React would remount one of them on any list change.
35
+ key={`${cue.from}-${i}`}
36
+ from={cue.from}
37
+ layout="none"
38
+ >
39
+ <Audio
40
+ // An http(s) URL (or the editor's `/media/…`) passes through
41
+ // untouched, everything else is a name in the render's public dir
42
+ // — CoverInVideo's exact rule.
43
+ src={/^https?:\/\//.test(cue.soundFile) ? cue.soundFile : staticFile(cue.soundFile)}
44
+ volume={cue.gain}
45
+ />
46
+ </Sequence>
47
+ ))}
48
+ </>
49
+ );
50
+ };
package/src/index.ts CHANGED
@@ -4,8 +4,10 @@ export { VideoStage } from "./VideoStage";
4
4
  export { SceneLayer } from "./SceneLayer";
5
5
  export { Watermark } from "./Watermark";
6
6
  export { CoverInVideo } from "./CoverInVideo";
7
+ export { SfxTrack } from "./SfxTrack";
7
8
  export * from "./stage";
8
9
  export * from "./watermark-layout";
9
10
  export * from "./cover-in-video";
11
+ export * from "./sfx-track";
10
12
  export * from "./punch-plan";
11
13
  export * from "./caption-visibility";
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The `--sfx` sound track's props and frame math (core's `resolveSfxCues`
3
+ * owns the word→output-time half and the gain product).
4
+ *
5
+ * Pure and JSX-free, `cover-in-video.ts`'s posture: the props gate, the frame
6
+ * rounding and the volume clamp are the whole behavior, and this package
7
+ * carries no jsdom — none of it would be assertable inside the component.
8
+ */
9
+
10
+ /** One effect: a staged file, an OUTPUT-time instant, a mix level. */
11
+ export interface SfxCueProps {
12
+ /** File under the render's public dir — `sfx/<id>.<ext>` (or a `/media/…` URL). */
13
+ soundFile: string;
14
+ atSec: number;
15
+ gain: number;
16
+ }
17
+
18
+ /**
19
+ * The loudest a cue may play. `HTMLMediaElement.volume` THROWS an
20
+ * IndexSizeError above 1, so an un-clamped gain would take the editor's
21
+ * preview down entirely — while the render, which mixes the samples itself,
22
+ * would happily amplify. Preview and render must agree, so both get the clamp
23
+ * and the ceiling is 1.
24
+ */
25
+ export const SFX_MAX_VOLUME = 1;
26
+
27
+ /**
28
+ * Whether a render-props `sfxCues` entry is a cue this renderer will mount —
29
+ * `coverInVideoPropsFor`'s posture (parse, never coerce, CLAUDE.md):
30
+ * render-props.json is user-visible and hand-editable, every pre-feature file
31
+ * has no key at all, and a mangled entry must fall back to SILENCE rather than
32
+ * mount an `undefined` src or a NaN-frame Sequence over the take.
33
+ *
34
+ * Per ENTRY, not all-or-nothing: one bad cue costs that cue, the drop-bad-item
35
+ * rule the whole SFX path is built on.
36
+ */
37
+ export function sfxCuesFor(value: unknown): SfxCueProps[] {
38
+ if (!Array.isArray(value)) return [];
39
+ const cues: SfxCueProps[] = [];
40
+ for (const entry of value) {
41
+ if (typeof entry !== "object" || entry === null) continue;
42
+ const v = entry as { soundFile?: unknown; atSec?: unknown; gain?: unknown };
43
+ if (typeof v.soundFile !== "string" || v.soundFile.length === 0) continue;
44
+ if (typeof v.atSec !== "number" || !Number.isFinite(v.atSec) || v.atSec < 0) continue;
45
+ // An absent gain is 1 (play it as recorded); a mangled one is refused
46
+ // rather than defaulted, because a wrong LEVEL is audible and silent
47
+ // about being wrong.
48
+ if (v.gain !== undefined && (typeof v.gain !== "number" || !Number.isFinite(v.gain))) continue;
49
+ cues.push({
50
+ soundFile: v.soundFile,
51
+ atSec: v.atSec,
52
+ gain: Math.min(SFX_MAX_VOLUME, Math.max(0, v.gain ?? 1)),
53
+ });
54
+ }
55
+ return cues;
56
+ }
57
+
58
+ /**
59
+ * The cues that actually have a frame to fire on, with that frame.
60
+ *
61
+ * `Math.round`, matching `frameWindow`'s start (FINDINGS §115) — an effect is
62
+ * an instant, so there is no end time whose independent rounding could collide
63
+ * with it. A cue at or past the composition's last frame is DROPPED rather
64
+ * than clamped to it: a whoosh planned for a moment the render no longer
65
+ * reaches must not pile onto the final frame with everything else that fell
66
+ * off the end.
67
+ */
68
+ export function visibleSfxCues(
69
+ cues: readonly SfxCueProps[],
70
+ fps: number,
71
+ durationInFrames: number,
72
+ ): Array<SfxCueProps & { from: number }> {
73
+ const out: Array<SfxCueProps & { from: number }> = [];
74
+ for (const cue of cues) {
75
+ const from = Math.round(cue.atSec * fps);
76
+ if (from < 0 || from >= durationInFrames) continue;
77
+ out.push({ ...cue, from });
78
+ }
79
+ return out;
80
+ }