@ossclip/scenes 0.1.35 → 0.1.37
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 +2 -2
- package/src/EdlVideo.tsx +62 -7
- package/src/VideoStage.tsx +37 -1
- package/src/color-grade.ts +82 -0
- package/src/frames.ts +16 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossclip/scenes",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.37",
|
|
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.
|
|
22
|
+
"@ossclip/core": "0.1.37"
|
|
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>
|
package/src/VideoStage.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|