@ossclip/scenes 0.1.34 → 0.1.36

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.34",
3
+ "version": "0.1.36",
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.34"
22
+ "@ossclip/core": "0.1.36"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "react": ">=18",
package/src/EdlVideo.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import React, { useMemo } from "react";
2
2
  import { AbsoluteFill, OffthreadVideo, Sequence, useVideoConfig } from "remotion";
3
3
  import type { KeptSpan } from "@ossclip/core/browser";
4
- import { frameWindow } from "./frames";
4
+ import { frameWindow, playableSpans } from "./frames";
5
5
  import { punchScalesFor, type PunchPlan } from "./punch-plan";
6
6
 
7
7
  export interface EdlVideoProps {
@@ -30,6 +30,11 @@ export interface EdlVideoProps {
30
30
  * and lets the backdrop through.
31
31
  */
32
32
  background?: string;
33
+ /**
34
+ * Per-window user gain (cue `video.volume`), output clock. Empty/absent =
35
+ * unity everywhere — the pre-feature tree, byte for byte.
36
+ */
37
+ gain?: readonly GainSegment[];
33
38
  }
34
39
 
35
40
  /**
@@ -37,17 +42,43 @@ export interface EdlVideoProps {
37
42
  * Jump cuts are concealed by alternating a slight punch-in whenever the
38
43
  * removed gap is long enough to produce a visible jump.
39
44
  */
45
+ /** One window of user audio gain on the OUTPUT clock (cue windows). */
46
+ export interface GainSegment {
47
+ startSec: number;
48
+ endSec: number;
49
+ gain: number;
50
+ }
51
+
52
+ /**
53
+ * The user gain at one output second — 1 outside every segment. Last match
54
+ * wins, mirroring how later cues paint over earlier ones; windows come from
55
+ * cues, which tile rather than overlap, so the rule is a tiebreak, not a
56
+ * feature.
57
+ */
58
+ export function gainAtSec(segments: readonly GainSegment[], sec: number): number {
59
+ let g = 1;
60
+ for (const s of segments) if (sec >= s.startSec && sec < s.endSec) g = s.gain;
61
+ return g;
62
+ }
63
+
40
64
  export const EdlVideo: React.FC<EdlVideoProps> = ({
41
65
  src,
42
- spans,
66
+ spans: rawSpans,
43
67
  punchInScale = 1.07,
44
68
  punch = null,
45
69
  punchThresholdSec = 0.15,
46
70
  audioFadeSec = 0.01,
47
71
  background = "black",
72
+ gain = [],
48
73
  }) => {
49
74
  const { fps } = useVideoConfig();
50
75
 
76
+ // A span whose source window rounds to zero frames would mount
77
+ // <OffthreadVideo trimBefore={n} trimAfter={n}> — a Remotion validation
78
+ // THROW that blanks the whole Player (the 2026-08-31 ADK crash). Filtered
79
+ // here, not just at the writers, so docs saved by older versions render.
80
+ const spans = useMemo(() => playableSpans(rawSpans, fps), [rawSpans, fps]);
81
+
51
82
  // Extracted to punch-plan.ts so the mask/parity interaction is testable
52
83
  // without mounting a composition; the loop there is the reference
53
84
  // implementation the Premiere export mirrors.
@@ -65,13 +96,24 @@ export const EdlVideo: React.FC<EdlVideoProps> = ({
65
96
  // duration that is one frame long also skews the fade ramp below,
66
97
  // which measures against `durationInFrames`.
67
98
  const { from, durationInFrames } = frameWindow(sp.outIn, sp.outOut, fps);
99
+ // premountFor 3s, up from 1s (field report 2026-08-31): each span is
100
+ // its own <video> seeking a large mezzanine, and one second of
101
+ // premount was not always enough — entering the next span played
102
+ // black/silent until a scrub-back forced a reload. Three seconds
103
+ // costs at most one extra warm element and covers a cold seek.
68
104
  return (
69
- <Sequence key={i} from={from} durationInFrames={durationInFrames} premountFor={fps}>
105
+ <Sequence key={i} from={from} durationInFrames={durationInFrames} premountFor={fps * 3}>
70
106
  <AbsoluteFill style={{ transform: `scale(${scales[i]})` }}>
71
107
  <OffthreadVideo
72
108
  src={src}
73
109
  trimBefore={Math.round(sp.srcIn * fps)}
74
110
  trimAfter={Math.round(sp.srcOut * fps)}
111
+ // A span whose media is STILL not ready pauses the player
112
+ // instead of playing on silently over a black frame — the
113
+ // stall is visible and recovers by itself, where the silent
114
+ // variant looked broken until the user scrubbed (same field
115
+ // report).
116
+ pauseWhenBuffering
75
117
  style={{
76
118
  width: "100%",
77
119
  height: "100%",
@@ -80,12 +122,25 @@ export const EdlVideo: React.FC<EdlVideoProps> = ({
80
122
  // for a portrait source, horizontal for a landscape one.
81
123
  objectPosition: "var(--ossclip-obj-x, 50%) var(--ossclip-obj-y, 50%)",
82
124
  }}
83
- volume={(f) =>
84
- Math.max(
125
+ volume={(f) => {
126
+ const fade = Math.max(
85
127
  0,
86
128
  Math.min(1, (f + 1) / fadeFrames, (durationInFrames - f) / fadeFrames),
87
- )
88
- }
129
+ );
130
+ // User gain composes ON the fade. Boosts above 1 land in
131
+ // the render via allowAmplificationDuringRender below, and
132
+ // in the Player via its WebAudio gain node — which is OFF
133
+ // by default: without useWebAudioApi the preview path is
134
+ // literally `element.volume = Math.min(volume, 1)`
135
+ // (remotion/use-amplification, shouldUseTraditionalVolume),
136
+ // so a 200% take sounded identical to 100% (field report
137
+ // 2026-08-31). Enabled only when a boost exists — the
138
+ // routed-through-AudioContext path is the exception, not
139
+ // the default audio graph.
140
+ return fade * gainAtSec(gain, (from + f) / fps);
141
+ }}
142
+ allowAmplificationDuringRender
143
+ useWebAudioApi={gain.some((g) => g.gain > 1)}
89
144
  />
90
145
  </AbsoluteFill>
91
146
  </Sequence>
@@ -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
+ };
@@ -17,6 +17,7 @@ import {
17
17
  type SourceFit,
18
18
  type SpanLike,
19
19
  } from "./content-crop";
20
+ import { colorGradeFilterId, stageFilterFor, type ColorGradeProps } from "./color-grade";
20
21
 
21
22
  /**
22
23
  * The stage (PHASE1 §1): a solid backdrop that fades in when a scene demotes
@@ -68,6 +69,13 @@ export const VideoStage: React.FC<{
68
69
  * without it there is nothing to fit and this falls back to cover.
69
70
  */
70
71
  sourceFit?: SourceFit;
72
+ /**
73
+ * The `--grade` SVG filter spec, already parsed by the composition
74
+ * (`colorGradePropsFor` — parse, never coerce). Absent/null means no grade
75
+ * and ZERO new DOM: no `<svg>`, no `url()` in the filter list, so a
76
+ * grade-less render builds the exact tree it always did.
77
+ */
78
+ colorGrade?: ColorGradeProps | null;
71
79
  children: React.ReactNode;
72
80
  }> = ({
73
81
  cues,
@@ -81,6 +89,7 @@ export const VideoStage: React.FC<{
81
89
  sourceSize,
82
90
  contentCropMode = "cover",
83
91
  sourceFit = "cover",
92
+ colorGrade,
84
93
  children,
85
94
  }) => {
86
95
  const frame = useCurrentFrame();
@@ -131,8 +140,33 @@ export const VideoStage: React.FC<{
131
140
  // consumed by it (see contentTransformFor's contract).
132
141
  const contentTransform = contentTransformFor(zoom, userScale, userDx, userDy);
133
142
 
143
+ // Value-derived id (see colorGradeFilterId): two stages in one document can
144
+ // only collide when their grades are identical, which makes the collision
145
+ // a no-op instead of one stage silently wearing the other's grade.
146
+ const gradeId = colorGrade ? colorGradeFilterId(colorGrade) : null;
147
+
134
148
  return (
135
149
  <AbsoluteFill>
150
+ {colorGrade ? (
151
+ // Zero-sized and absolute: the element exists only to define the
152
+ // filter the slot's `filter: url(#…)` references; it must never take
153
+ // layout space in the stage.
154
+ <svg width={0} height={0} style={{ position: "absolute" }} aria-hidden>
155
+ {/* colorInterpolationFilters="sRGB" is LOAD-BEARING: the SVG spec
156
+ defaults filter math to linearRGB, but core sampled the transfer
157
+ tables and built the matrix in sRGB (gamma) space — without the
158
+ override the browser un-gammas the pixels first and the whole
159
+ grade shifts brighter/washed. */}
160
+ <filter id={gradeId!} colorInterpolationFilters="sRGB">
161
+ <feComponentTransfer>
162
+ <feFuncR type="table" tableValues={colorGrade.tableR.join(" ")} />
163
+ <feFuncG type="table" tableValues={colorGrade.tableG.join(" ")} />
164
+ <feFuncB type="table" tableValues={colorGrade.tableB.join(" ")} />
165
+ </feComponentTransfer>
166
+ <feColorMatrix type="matrix" values={colorGrade.colorMatrix.join(" ")} />
167
+ </filter>
168
+ </svg>
169
+ ) : null}
136
170
  <AbsoluteFill style={{ background: theme.bg, opacity: backdrop }} />
137
171
  <div
138
172
  // Which scene's framing a grab on the picture edits (PLAN 2026-07-30
@@ -163,7 +197,9 @@ export const VideoStage: React.FC<{
163
197
  style={{
164
198
  position: "absolute",
165
199
  inset: 0,
166
- filter: slot.blurPx > 0.5 ? `blur(${slot.blurPx}px)` : undefined,
200
+ // Grade before blur (stageFilterFor has the ordering argument);
201
+ // without a grade this is the exact blur string it always was.
202
+ filter: stageFilterFor(gradeId, slot.blurPx),
167
203
  transform: contentTransform,
168
204
  // Zoom toward the face, which the crop bias keeps in the upper part.
169
205
  transformOrigin: "50% 40%",
@@ -0,0 +1,82 @@
1
+ import { z } from "zod/v4";
2
+
3
+ /**
4
+ * The `--grade` color pipeline's render half (core's color-grade.ts owns the
5
+ * math: `gradeToSvgFilterSpec` decomposes the grade into per-channel transfer
6
+ * tables plus one 5x4 color matrix). This module is the props gate, the
7
+ * filter id and the CSS `filter:` composition — everything about the SVG
8
+ * filter that is assertable without a DOM.
9
+ *
10
+ * Pure and JSX-free (house rule, `sfx-track.ts`'s posture): the component
11
+ * just serializes these values into `<feFuncR tableValues>` and friends.
12
+ */
13
+
14
+ /**
15
+ * The precomputed spec, verbatim from `gradeToSvgFilterSpec`: 0..1 transfer
16
+ * table samples per channel, and a row-major 5x4 feColorMatrix. Zod (parse,
17
+ * never coerce, CLAUDE.md) rather than the neighbours' hand-rolled checks
18
+ * because the shape is four numeric arrays — exactly what a schema states
19
+ * more legibly than a loop. zod/v4's `z.number()` already refuses NaN and
20
+ * ±Infinity, which is the whole finiteness story a hand parser would need.
21
+ */
22
+ const colorGradeSchema = z.object({
23
+ tableR: z.array(z.number()),
24
+ tableG: z.array(z.number()),
25
+ tableB: z.array(z.number()),
26
+ // 20 exactly: feColorMatrix silently renders NOTHING (transparent output)
27
+ // on a malformed `values` list, which reads as "the video vanished", not
28
+ // "the grade is off" — so a truncated matrix must fail here instead.
29
+ colorMatrix: z.array(z.number()).length(20),
30
+ });
31
+
32
+ export type ColorGradeProps = z.infer<typeof colorGradeSchema>;
33
+
34
+ /**
35
+ * Whether a render-props `colorGrade` field is a spec this renderer will
36
+ * mount — `punchPropsFor`'s posture: render-props.json is user-visible and
37
+ * hand-editable, every pre-feature file has no key at all, and a mangled
38
+ * spec must fall back to NO grade (zero new DOM) rather than an feColorMatrix
39
+ * that blanks the picture or tables full of `undefined`.
40
+ */
41
+ export function colorGradePropsFor(value: unknown): ColorGradeProps | null {
42
+ const parsed = colorGradeSchema.safeParse(value);
43
+ return parsed.success ? parsed.data : null;
44
+ }
45
+
46
+ /**
47
+ * A DOM id for the filter, derived from the VALUES (FNV-1a over the sample
48
+ * list) rather than from a counter or a random suffix: SVG filter ids are
49
+ * document-global, and if two stages ever share a document (editor preview
50
+ * next to a thumbnail), a value-derived id makes collision harmless — equal
51
+ * ids mean equal filter definitions, so whichever `<filter>` wins the lookup
52
+ * applies the same grade. A counter would make the collision silently apply
53
+ * one stage's grade to the other.
54
+ */
55
+ export function colorGradeFilterId(spec: ColorGradeProps): string {
56
+ const values = [...spec.tableR, ...spec.tableG, ...spec.tableB, ...spec.colorMatrix];
57
+ let hash = 0x811c9dc5;
58
+ for (const v of values) {
59
+ const s = String(v);
60
+ for (let i = 0; i < s.length; i++) {
61
+ hash ^= s.charCodeAt(i);
62
+ hash = Math.imul(hash, 0x01000193);
63
+ }
64
+ }
65
+ return `ossclip-grade-${(hash >>> 0).toString(16)}`;
66
+ }
67
+
68
+ /**
69
+ * The video slot's composed `filter:` value. One CSS filter list takes both
70
+ * an SVG `url()` reference and CSS filter functions; the grade goes FIRST so
71
+ * the blur softens the graded picture — blurring first would average raw
72
+ * pixels and then re-map the averages, visibly haloing hard edges. The 0.5px
73
+ * blur floor predates the grade (VideoStage has always skipped sub-pixel
74
+ * blurs) and is kept so a grade-less render emits the exact string it always
75
+ * did.
76
+ */
77
+ export function stageFilterFor(gradeId: string | null, blurPx: number): string | undefined {
78
+ const parts: string[] = [];
79
+ if (gradeId) parts.push(`url(#${gradeId})`);
80
+ if (blurPx > 0.5) parts.push(`blur(${blurPx}px)`);
81
+ return parts.length > 0 ? parts.join(" ") : undefined;
82
+ }
package/src/frames.ts CHANGED
@@ -28,3 +28,19 @@ export function frameWindow(
28
28
  const to = Math.round(endSec * fps);
29
29
  return { from, durationInFrames: Math.max(1, to - from) };
30
30
  }
31
+
32
+ /**
33
+ * Drop spans whose SOURCE window rounds to zero frames at this fps. Such a
34
+ * span cannot be played — `<OffthreadVideo trimBefore={n} trimAfter={n}>` is
35
+ * a Remotion validation THROW, and inside the Player that error boundary
36
+ * blanks the entire preview and stops playback (the 2026-08-31 ADK crash:
37
+ * a 3-decimal-rounded user cut left a 125µs keep sliver). recut.ts absorbs
38
+ * new slivers at the source; this filter is the defense-in-depth for docs
39
+ * older versions already saved.
40
+ */
41
+ export function playableSpans<T extends { srcIn: number; srcOut: number }>(
42
+ spans: readonly T[],
43
+ fps: number,
44
+ ): T[] {
45
+ return spans.filter((sp) => Math.round(sp.srcOut * fps) > Math.round(sp.srcIn * fps));
46
+ }
package/src/index.ts CHANGED
@@ -4,8 +4,11 @@ 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";
13
+ export * from "./color-grade";
11
14
  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
+ }