@ossclip/scenes 0.1.23 → 0.1.25
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/CaptionTrack.tsx +170 -12
- package/src/EdlVideo.tsx +17 -11
- package/src/VideoStage.tsx +25 -18
- package/src/content-crop.ts +76 -1
- package/src/index.ts +1 -0
- package/src/punch-plan.ts +75 -0
- package/src/stage.ts +72 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossclip/scenes",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
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.25"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"react": ">=18",
|
package/src/CaptionTrack.tsx
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
AbsoluteFill,
|
|
4
|
+
Sequence,
|
|
5
|
+
continueRender,
|
|
6
|
+
delayRender,
|
|
7
|
+
staticFile,
|
|
8
|
+
useCurrentFrame,
|
|
9
|
+
useVideoConfig,
|
|
10
|
+
} from "remotion";
|
|
3
11
|
import {
|
|
4
12
|
captionAnchorOf,
|
|
13
|
+
captionsNeedNastaliq,
|
|
5
14
|
lineDirection,
|
|
15
|
+
NASTALIQ_FONT_NAME,
|
|
16
|
+
NASTALIQ_FONT_REL,
|
|
6
17
|
type CaptionLine,
|
|
7
18
|
type SceneCue,
|
|
19
|
+
type Theme,
|
|
8
20
|
} from "@ossclip/core/browser";
|
|
9
|
-
import { safeAreaFor, activeCueAt } from "./stage";
|
|
21
|
+
import { safeAreaFor, activeCueAt, captionFontSizeFor, type FrameSize } from "./stage";
|
|
10
22
|
import { frameWindow } from "./frames";
|
|
11
23
|
import { captionAnchorAvoiding, regionsDuring, type OccupiedRegion } from "./source-fit";
|
|
12
24
|
import { CAPTION_POP_SEC, easeOutQuad } from "./motion";
|
|
@@ -17,8 +29,16 @@ export interface CaptionTrackProps {
|
|
|
17
29
|
cues?: SceneCue[];
|
|
18
30
|
/** Vertical center of the caption block, as a fraction of frame height. */
|
|
19
31
|
verticalAnchor?: number;
|
|
32
|
+
/** Explicit size wins outright; unset = frame-derived (`captionFontSizeFor`). */
|
|
20
33
|
fontSizePx?: number;
|
|
21
34
|
activeColor?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Design tokens for the caption type (F6, 2026-08-16): `fontDisplay` and
|
|
37
|
+
* `fg` replace the historical literals so a config theme reaches the
|
|
38
|
+
* captions. Optional and absent-means-the-old-literals (`captionTypography`)
|
|
39
|
+
* so pre-theme callers and render-props render unchanged.
|
|
40
|
+
*/
|
|
41
|
+
theme?: Theme;
|
|
22
42
|
/**
|
|
23
43
|
* The comment-CTA word: at the moment it is ASKED FOR, the caption word
|
|
24
44
|
* renders quoted and capitalized — reinforcing the ask for muted viewers
|
|
@@ -68,6 +88,116 @@ export function inCtaWindow(
|
|
|
68
88
|
);
|
|
69
89
|
}
|
|
70
90
|
|
|
91
|
+
/** The size a line renders at: an explicit prop wins outright, else the frame default. */
|
|
92
|
+
export function resolveCaptionFontSize(
|
|
93
|
+
fontSizePx: number | undefined,
|
|
94
|
+
frame: FrameSize,
|
|
95
|
+
): number {
|
|
96
|
+
return fontSizePx ?? captionFontSizeFor(frame);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stroke width for a caption font size. The historical 10px stroke was tuned
|
|
101
|
+
* against 64px portrait type; fixed at 10 it swallows the smaller landscape
|
|
102
|
+
* letterforms (44px default, 2026-08-16), so it rides the font instead.
|
|
103
|
+
* Portrait output is byte-identical: 64 → 10.
|
|
104
|
+
*/
|
|
105
|
+
export function captionStrokePx(fontSizePx: number): number {
|
|
106
|
+
return (fontSizePx * 10) / 64;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Caption typography from the theme (F6, 2026-08-16). The fallbacks ARE the
|
|
111
|
+
* historical literals, so a themeless caller (pre-theme render-props, the
|
|
112
|
+
* editor before it passes one) renders byte-identically — and the default
|
|
113
|
+
* theme differs from them only in spelling white as #FFFFFF, the same color.
|
|
114
|
+
* The stroke's rgba is deliberately NOT themed: it is an outline for
|
|
115
|
+
* contrast against arbitrary video, not a palette color, and a light theme
|
|
116
|
+
* fg with a theme-matched light stroke would erase caption legibility.
|
|
117
|
+
* Pure — the theme prop's honored/absent matrix is testable without Remotion.
|
|
118
|
+
*/
|
|
119
|
+
export function captionTypography(theme: Theme | undefined): {
|
|
120
|
+
fontFamily: string;
|
|
121
|
+
color: string;
|
|
122
|
+
} {
|
|
123
|
+
return {
|
|
124
|
+
fontFamily:
|
|
125
|
+
theme?.fontDisplay ?? "'Inter', 'Helvetica Neue', 'Arial Black', Arial, sans-serif",
|
|
126
|
+
color: theme?.fg ?? "white",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Per-line font stack (bundled Nastaliq, 2026-08-17). An RTL line leads with
|
|
132
|
+
* the bundled Noto Nastaliq Urdu; an LTR line keeps the resolved base stack
|
|
133
|
+
* BYTE-IDENTICAL, so Latin captions cannot change appearance. PREPENDED, not
|
|
134
|
+
* replacing: a config theme's `fontDisplay` (F6) stays the rest of the stack,
|
|
135
|
+
* so an RTL line still falls back to the user's own fonts for anything
|
|
136
|
+
* Nastaliq lacks. Within an RTL line, embedded Latin loanwords and digits DO
|
|
137
|
+
* render in Nastaliq's own Latin glyphs (verified against the v3.007 TTF's
|
|
138
|
+
* cmap: A–z and 0–9 are covered) — accepted, since one face per line keeps
|
|
139
|
+
* the weight consistent through a code-switched Urdu line.
|
|
140
|
+
*/
|
|
141
|
+
export function captionFontFamilyFor(
|
|
142
|
+
direction: "rtl" | "ltr",
|
|
143
|
+
baseFontFamily: string,
|
|
144
|
+
): string {
|
|
145
|
+
return direction === "rtl" ? `'${NASTALIQ_FONT_NAME}', ${baseFontFamily}` : baseFontFamily;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Per-line line-height. Nastaliq's diagonal stacking runs far above and below
|
|
150
|
+
* the Latin baseline — the bundled face declares ascender−descender = 2.5×
|
|
151
|
+
* its em (read from the v3.007 TTF's hhea table; Inter is ~1.2×) — so at the
|
|
152
|
+
* historical 1.15 a wrapped Urdu line's stacks collide with the row beneath.
|
|
153
|
+
* RTL lines get 1.9: glyphs are never CLIPPED (nothing here sets
|
|
154
|
+
* overflow:hidden — the box is just spacing), and most caption lines are a
|
|
155
|
+
* single ≤3-word row, so going all the way to the font's own 2.5 would only
|
|
156
|
+
* push a rare two-row line out of its safe-area band. LTR lines keep 1.15,
|
|
157
|
+
* the byte-identical historical value.
|
|
158
|
+
*/
|
|
159
|
+
export function captionLineHeightFor(direction: "rtl" | "ltr"): number {
|
|
160
|
+
return direction === "rtl" ? 1.9 : 1.15;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Registers the bundled Nastaliq face for this render. FontFace + delayRender
|
|
165
|
+
* rather than a <style> @font-face: the render seeks and screenshots, so a
|
|
166
|
+
* lazily-fetched stylesheet font can miss the first captioned frames — the
|
|
167
|
+
* same no-wall-clock reasoning as the word-pop animation above. Mounted only
|
|
168
|
+
* when a line actually lays out RTL (`captionsNeedNastaliq`, the predicate
|
|
169
|
+
* produce's font copy shares), so pure-Latin runs fetch nothing. A load
|
|
170
|
+
* FAILURE continues the render on the fallback stack instead of cancelling:
|
|
171
|
+
* captions are the accessibility layer, and a wrong font still beats a dead
|
|
172
|
+
* render.
|
|
173
|
+
*/
|
|
174
|
+
const NastaliqFontLoader: React.FC = () => {
|
|
175
|
+
const [handle] = React.useState(() => delayRender(`load ${NASTALIQ_FONT_NAME}`));
|
|
176
|
+
React.useEffect(() => {
|
|
177
|
+
let done = false;
|
|
178
|
+
const finish = () => {
|
|
179
|
+
if (!done) {
|
|
180
|
+
done = true;
|
|
181
|
+
continueRender(handle);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const face = new FontFace(
|
|
185
|
+
NASTALIQ_FONT_NAME,
|
|
186
|
+
`url("${staticFile(NASTALIQ_FONT_REL)}") format("truetype")`,
|
|
187
|
+
// The captions render at fontWeight 900; declaring the (nominally 700)
|
|
188
|
+
// Bold face AS 900 makes it the exact match there, so the browser never
|
|
189
|
+
// synthetic-bolds Nastaliq's already-dense strokes on top.
|
|
190
|
+
{ weight: "900" },
|
|
191
|
+
);
|
|
192
|
+
face.load().then((loaded) => {
|
|
193
|
+
document.fonts.add(loaded);
|
|
194
|
+
finish();
|
|
195
|
+
}, finish);
|
|
196
|
+
return finish;
|
|
197
|
+
}, [handle]);
|
|
198
|
+
return null;
|
|
199
|
+
};
|
|
200
|
+
|
|
71
201
|
const LineView: React.FC<{
|
|
72
202
|
line: CaptionLine;
|
|
73
203
|
/** This line's first word's index in the WHOLE caption stream — the id the
|
|
@@ -76,9 +206,22 @@ const LineView: React.FC<{
|
|
|
76
206
|
verticalAnchor: number;
|
|
77
207
|
fontSizePx: number;
|
|
78
208
|
activeColor: string;
|
|
209
|
+
/** Resolved by `captionTypography` in the parent — one resolution per track. */
|
|
210
|
+
fontFamily: string;
|
|
211
|
+
textColor: string;
|
|
79
212
|
ctaKeyword?: string;
|
|
80
213
|
ctaWindow?: { startSec: number; endSec: number };
|
|
81
|
-
}> = ({
|
|
214
|
+
}> = ({
|
|
215
|
+
line,
|
|
216
|
+
wordOffset,
|
|
217
|
+
verticalAnchor,
|
|
218
|
+
fontSizePx,
|
|
219
|
+
activeColor,
|
|
220
|
+
fontFamily,
|
|
221
|
+
textColor,
|
|
222
|
+
ctaKeyword,
|
|
223
|
+
ctaWindow,
|
|
224
|
+
}) => {
|
|
82
225
|
const frame = useCurrentFrame();
|
|
83
226
|
const { fps, width, height } = useVideoConfig();
|
|
84
227
|
const safeArea = safeAreaFor({ width, height });
|
|
@@ -109,16 +252,20 @@ const LineView: React.FC<{
|
|
|
109
252
|
// Keep caption text clear of the platform's right-hand action rail.
|
|
110
253
|
paddingLeft: `${safeArea.left * 100}%`,
|
|
111
254
|
paddingRight: `${safeArea.right * 100}%`,
|
|
112
|
-
|
|
113
|
-
|
|
255
|
+
// Direction-keyed (bundled Nastaliq, 2026-08-17): RTL lines lead
|
|
256
|
+
// with the bundled face and get its deeper line box; LTR lines are
|
|
257
|
+
// byte-identical to before — see the two helpers for the metrics.
|
|
258
|
+
fontFamily: captionFontFamilyFor(direction, fontFamily),
|
|
114
259
|
fontWeight: 900,
|
|
115
260
|
fontSize: fontSizePx,
|
|
116
|
-
lineHeight:
|
|
261
|
+
lineHeight: captionLineHeightFor(direction),
|
|
117
262
|
textAlign: "center",
|
|
118
|
-
color:
|
|
119
|
-
WebkitTextStroke:
|
|
263
|
+
color: textColor,
|
|
264
|
+
WebkitTextStroke: `${captionStrokePx(fontSizePx)}px rgba(0,0,0,0.85)`,
|
|
120
265
|
paintOrder: "stroke fill",
|
|
121
|
-
|
|
266
|
+
// Shadow blur rides the font for the same reason as the stroke
|
|
267
|
+
// (portrait byte-identical: 64 → 24).
|
|
268
|
+
textShadow: `0 4px ${(fontSizePx * 24) / 64}px rgba(0,0,0,0.55)`,
|
|
122
269
|
}}
|
|
123
270
|
>
|
|
124
271
|
{line.words.map((w, i) => {
|
|
@@ -161,7 +308,7 @@ const LineView: React.FC<{
|
|
|
161
308
|
// Colour stays keyed to the window, not the ramp: colour has
|
|
162
309
|
// no in-between worth animating, and lerping it would fight
|
|
163
310
|
// the stroke.
|
|
164
|
-
color: inWindow ? activeColor :
|
|
311
|
+
color: inWindow ? activeColor : textColor,
|
|
165
312
|
}}
|
|
166
313
|
>
|
|
167
314
|
{/* Per WORD, not per line: a line straddling the cue boundary
|
|
@@ -184,14 +331,19 @@ export const CaptionTrack: React.FC<CaptionTrackProps> = ({
|
|
|
184
331
|
lines,
|
|
185
332
|
cues = [],
|
|
186
333
|
verticalAnchor = 0.76,
|
|
187
|
-
fontSizePx
|
|
334
|
+
fontSizePx,
|
|
188
335
|
activeColor = "#FFE14D",
|
|
336
|
+
theme,
|
|
189
337
|
ctaKeyword,
|
|
190
338
|
ctaWindow,
|
|
191
339
|
sourceTextRegions = [],
|
|
192
340
|
}) => {
|
|
193
341
|
const { fps, width, height } = useVideoConfig();
|
|
194
342
|
const frame = { width, height };
|
|
343
|
+
// Frame-derived default (2026-08-16): an explicit prop still wins outright.
|
|
344
|
+
const resolvedFont = resolveCaptionFontSize(fontSizePx, frame);
|
|
345
|
+
// One resolution for the whole track — every line shares the theme's type.
|
|
346
|
+
const { fontFamily, color: textColor } = captionTypography(theme);
|
|
195
347
|
// Each line's first-word index in the whole stream, so a word's id is
|
|
196
348
|
// stable regardless of which line the layout put it on.
|
|
197
349
|
const offsets: number[] = [];
|
|
@@ -202,6 +354,10 @@ export const CaptionTrack: React.FC<CaptionTrackProps> = ({
|
|
|
202
354
|
}
|
|
203
355
|
return (
|
|
204
356
|
<AbsoluteFill>
|
|
357
|
+
{/* Mounted once for the whole track, and only when some line is RTL —
|
|
358
|
+
the same predicate produce keys the font copy on, so the fetch and
|
|
359
|
+
the file can't disagree. */}
|
|
360
|
+
{captionsNeedNastaliq(lines) ? <NastaliqFontLoader /> : null}
|
|
205
361
|
{lines.map((line, i) => {
|
|
206
362
|
const active = cues.length > 0 ? activeCueAt(cues, line.start) : null;
|
|
207
363
|
// ONE rect-aware path for every line (R11 Task 2b). The old split —
|
|
@@ -237,8 +393,10 @@ export const CaptionTrack: React.FC<CaptionTrackProps> = ({
|
|
|
237
393
|
verticalAnchor={anchor}
|
|
238
394
|
// Per-scene size multiplier (R16 §64) — resolved per line like
|
|
239
395
|
// the anchor, from the cue the line starts under.
|
|
240
|
-
fontSizePx={
|
|
396
|
+
fontSizePx={resolvedFont * (active?.captionScale ?? 1)}
|
|
241
397
|
activeColor={activeColor}
|
|
398
|
+
fontFamily={fontFamily}
|
|
399
|
+
textColor={textColor}
|
|
242
400
|
ctaKeyword={ctaKeyword}
|
|
243
401
|
ctaWindow={ctaWindow}
|
|
244
402
|
/>
|
package/src/EdlVideo.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { useMemo } from "react";
|
|
|
2
2
|
import { AbsoluteFill, OffthreadVideo, Sequence, useVideoConfig } from "remotion";
|
|
3
3
|
import type { KeptSpan } from "@ossclip/core/browser";
|
|
4
4
|
import { frameWindow } from "./frames";
|
|
5
|
+
import { punchScalesFor, type PunchPlan } from "./punch-plan";
|
|
5
6
|
|
|
6
7
|
export interface EdlVideoProps {
|
|
7
8
|
src: string;
|
|
@@ -9,6 +10,14 @@ export interface EdlVideoProps {
|
|
|
9
10
|
spans: KeptSpan[];
|
|
10
11
|
/** Scale applied on alternating segments to conceal jump cuts. */
|
|
11
12
|
punchInScale?: number;
|
|
13
|
+
/**
|
|
14
|
+
* The face-only punch plan from render-props (punch-plan.ts). When present
|
|
15
|
+
* its scale replaces `punchInScale` and its mask gates which spans render
|
|
16
|
+
* it; absent/null is the LEGACY contract — `punchInScale` everywhere — so
|
|
17
|
+
* every pre-feature render-props renders unchanged. Callers gate the raw
|
|
18
|
+
* JSON through `punchPropsFor` first (parse, never coerce).
|
|
19
|
+
*/
|
|
20
|
+
punch?: PunchPlan | null;
|
|
12
21
|
/** Removed gaps shorter than this don't toggle the punch-in (cut is invisible anyway). */
|
|
13
22
|
punchThresholdSec?: number;
|
|
14
23
|
/** Audio micro-fade at each cut boundary, in seconds. */
|
|
@@ -32,23 +41,20 @@ export const EdlVideo: React.FC<EdlVideoProps> = ({
|
|
|
32
41
|
src,
|
|
33
42
|
spans,
|
|
34
43
|
punchInScale = 1.07,
|
|
44
|
+
punch = null,
|
|
35
45
|
punchThresholdSec = 0.15,
|
|
36
46
|
audioFadeSec = 0.01,
|
|
37
47
|
background = "black",
|
|
38
48
|
}) => {
|
|
39
49
|
const { fps } = useVideoConfig();
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
out.push(punched ? punchInScale : 1);
|
|
49
|
-
}
|
|
50
|
-
return out;
|
|
51
|
-
}, [spans, punchInScale, punchThresholdSec]);
|
|
51
|
+
// Extracted to punch-plan.ts so the mask/parity interaction is testable
|
|
52
|
+
// without mounting a composition; the loop there is the reference
|
|
53
|
+
// implementation the Premiere export mirrors.
|
|
54
|
+
const scales = useMemo(
|
|
55
|
+
() => punchScalesFor(spans, punch, punchInScale, punchThresholdSec),
|
|
56
|
+
[spans, punch, punchInScale, punchThresholdSec],
|
|
57
|
+
);
|
|
52
58
|
|
|
53
59
|
const fadeFrames = Math.max(1, Math.round(audioFadeSec * fps));
|
|
54
60
|
|
package/src/VideoStage.tsx
CHANGED
|
@@ -3,15 +3,15 @@ import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
|
|
3
3
|
import type {
|
|
4
4
|
ContentRectSegment,
|
|
5
5
|
FaceCrop,
|
|
6
|
+
FramingSegment,
|
|
6
7
|
SceneCue,
|
|
7
8
|
Theme,
|
|
8
9
|
ZoomSegment,
|
|
9
10
|
} from "@ossclip/core/browser";
|
|
10
11
|
import { zoomScaleAt } from "@ossclip/core/browser";
|
|
11
|
-
import { activeCueAt, backdropOpacityAt, videoSlotAt } from "./stage";
|
|
12
|
+
import { activeCueAt, backdropOpacityAt, contentTransformFor, videoSlotAt } from "./stage";
|
|
12
13
|
import {
|
|
13
|
-
|
|
14
|
-
contentRectAtOutput,
|
|
14
|
+
activeCropBox,
|
|
15
15
|
sourceFitBox,
|
|
16
16
|
type ContentCropMode,
|
|
17
17
|
type SourceFit,
|
|
@@ -43,6 +43,14 @@ export const VideoStage: React.FC<{
|
|
|
43
43
|
* timeline for it would crop the same bars twice.
|
|
44
44
|
*/
|
|
45
45
|
contentTimeline?: ContentRectSegment[];
|
|
46
|
+
/**
|
|
47
|
+
* The render-time framing plan over SOURCE time — the props-based successor
|
|
48
|
+
* to the destructive normalization bake (2026-08-16 incident). Preferred
|
|
49
|
+
* over `contentTimeline` when present: the plan was computed FROM that
|
|
50
|
+
* timeline and already accounts for the bars. Absent means no plan, so
|
|
51
|
+
* every pre-existing render-props renders unchanged.
|
|
52
|
+
*/
|
|
53
|
+
framingTimeline?: FramingSegment[];
|
|
46
54
|
/** Kept spans, needed to read the timeline's SOURCE clock at an output time. */
|
|
47
55
|
spans?: SpanLike[];
|
|
48
56
|
/** The source's own pixel dimensions — the frame the timeline is measured in. */
|
|
@@ -68,6 +76,7 @@ export const VideoStage: React.FC<{
|
|
|
68
76
|
zoomPlan,
|
|
69
77
|
sourceTextRegions,
|
|
70
78
|
contentTimeline,
|
|
79
|
+
framingTimeline,
|
|
71
80
|
spans,
|
|
72
81
|
sourceSize,
|
|
73
82
|
contentCropMode = "cover",
|
|
@@ -117,13 +126,10 @@ export const VideoStage: React.FC<{
|
|
|
117
126
|
const userScale = userVideo?.scale ?? 1;
|
|
118
127
|
const userDx = userVideo?.dx ?? 0;
|
|
119
128
|
const userDy = userVideo?.dy ?? 0;
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
]
|
|
125
|
-
.filter(Boolean)
|
|
126
|
-
.join(" ") || undefined;
|
|
129
|
+
// Built OUTSIDE the crop on purpose: the transform wraps ContentCrop below,
|
|
130
|
+
// so a user correction composes ON TOP of the framing plan instead of being
|
|
131
|
+
// consumed by it (see contentTransformFor's contract).
|
|
132
|
+
const contentTransform = contentTransformFor(zoom, userScale, userDx, userDy);
|
|
127
133
|
|
|
128
134
|
return (
|
|
129
135
|
<AbsoluteFill>
|
|
@@ -182,6 +188,7 @@ export const VideoStage: React.FC<{
|
|
|
182
188
|
) : (
|
|
183
189
|
<ContentCrop
|
|
184
190
|
timeline={contentTimeline}
|
|
191
|
+
framing={framingTimeline}
|
|
185
192
|
spans={spans}
|
|
186
193
|
sourceSize={sourceSize}
|
|
187
194
|
mode={contentCropMode}
|
|
@@ -229,9 +236,14 @@ const FitBox: React.FC<{
|
|
|
229
236
|
* covers the slot. The resulting box carries the source's own aspect ratio, so
|
|
230
237
|
* the video's `object-fit: cover` has no overflow left to crop and its
|
|
231
238
|
* `object-position` becomes a no-op — the bias has already been spent here.
|
|
239
|
+
*
|
|
240
|
+
* A `framing` plan (2026-08-16) takes the same box-shaped path with its own
|
|
241
|
+
* windows and bias; the which-crop-wins decision lives in `activeCropBox`, a
|
|
242
|
+
* pure function, so this component stays a dumb box painter.
|
|
232
243
|
*/
|
|
233
244
|
const ContentCrop: React.FC<{
|
|
234
245
|
timeline?: ContentRectSegment[];
|
|
246
|
+
framing?: FramingSegment[];
|
|
235
247
|
spans?: SpanLike[];
|
|
236
248
|
sourceSize?: { width: number; height: number };
|
|
237
249
|
mode: ContentCropMode;
|
|
@@ -240,14 +252,9 @@ const ContentCrop: React.FC<{
|
|
|
240
252
|
posX: number;
|
|
241
253
|
posY: number;
|
|
242
254
|
children: React.ReactNode;
|
|
243
|
-
}> = ({ timeline, spans, sourceSize, mode, tSec, slot, posX, posY, children }) => {
|
|
244
|
-
const
|
|
245
|
-
if (!
|
|
246
|
-
|
|
247
|
-
const rect = contentRectAtOutput(timeline, spans ?? [], tSec, sourceSize);
|
|
248
|
-
if (rect.full) return passthrough;
|
|
249
|
-
|
|
250
|
-
const box = contentBox(mode, sourceSize, rect, slot, posX, posY);
|
|
255
|
+
}> = ({ timeline, framing, spans, sourceSize, mode, tSec, slot, posX, posY, children }) => {
|
|
256
|
+
const box = activeCropBox(framing, timeline, spans ?? [], tSec, sourceSize, mode, slot, posX, posY);
|
|
257
|
+
if (!box) return <div style={{ position: "absolute", inset: 0 }}>{children}</div>;
|
|
251
258
|
return (
|
|
252
259
|
<div
|
|
253
260
|
style={{
|
package/src/content-crop.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
contentRectAt,
|
|
3
|
+
type ContentRect,
|
|
4
|
+
type ContentRectSegment,
|
|
5
|
+
type FramingSegment,
|
|
6
|
+
} from "@ossclip/core/browser";
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* Rendering a source whose framing changes mid-take (PLAN Task C).
|
|
@@ -177,3 +182,73 @@ export function contentRectAtOutput(
|
|
|
177
182
|
}
|
|
178
183
|
return contentRectAt(timeline, sourceTimeAt(spans, outSec), source);
|
|
179
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The framing segment to render at an OUTPUT time — the props-based successor
|
|
188
|
+
* to the destructive normalization bake (2026-08-16 incident: the bake
|
|
189
|
+
* crop+scale+re-encoded the mezzanine, irreversibly; expressed as data, the
|
|
190
|
+
* same window renders as a per-frame box the editor can see and counteract).
|
|
191
|
+
*
|
|
192
|
+
* Mirrors `contentRectAtOutput` exactly: the same span→source time mapping,
|
|
193
|
+
* and the same edge clamping `contentRectAt` does — a time outside the
|
|
194
|
+
* timeline resolves to its nearest segment, so a rounding error at a boundary
|
|
195
|
+
* cannot flash the unframed picture back for one frame. Null only when there
|
|
196
|
+
* is no plan at all, which is the legacy passthrough.
|
|
197
|
+
*/
|
|
198
|
+
export function framingWindowAtOutput(
|
|
199
|
+
timeline: readonly FramingSegment[],
|
|
200
|
+
spans: readonly SpanLike[],
|
|
201
|
+
outSec: number,
|
|
202
|
+
): FramingSegment | null {
|
|
203
|
+
if (timeline.length === 0) return null;
|
|
204
|
+
const srcSec = sourceTimeAt(spans, outSec);
|
|
205
|
+
if (srcSec < timeline[0]!.startSec) return timeline[0]!;
|
|
206
|
+
for (const seg of timeline) {
|
|
207
|
+
if (srcSec >= seg.startSec && srcSec < seg.endSec) return seg;
|
|
208
|
+
}
|
|
209
|
+
return timeline[timeline.length - 1]!;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Which crop applies at this output moment, as the box to render — or null
|
|
214
|
+
* for the byte-identical passthrough every legacy render-props takes.
|
|
215
|
+
*
|
|
216
|
+
* Precedence: a framing plan wins over the letterbox content timeline. The
|
|
217
|
+
* plan was computed FROM that timeline and already accounts for the bars, so
|
|
218
|
+
* consulting both would crop the same pixels twice. A framing window renders
|
|
219
|
+
* through `contentCoverBox` with the segment's own bias as the anchor
|
|
220
|
+
* fractions — NOT the stage's face bias, which the plan already spent — so a
|
|
221
|
+
* "screen" full-rect window with a 0.5/0.5 bias reduces to plain centred
|
|
222
|
+
* cover: the same "one code path" property the module doc promises for
|
|
223
|
+
* content rects, and the reason the common case cannot drift.
|
|
224
|
+
*
|
|
225
|
+
* Pure and JSX-free on purpose: this IS the precedence decision, and it has
|
|
226
|
+
* to be testable without mounting a Remotion composition (house rule — pure
|
|
227
|
+
* logic separated from I/O).
|
|
228
|
+
*/
|
|
229
|
+
export function activeCropBox(
|
|
230
|
+
framing: readonly FramingSegment[] | undefined,
|
|
231
|
+
timeline: readonly ContentRectSegment[] | undefined,
|
|
232
|
+
spans: readonly SpanLike[],
|
|
233
|
+
outSec: number,
|
|
234
|
+
source: { width: number; height: number } | undefined,
|
|
235
|
+
mode: ContentCropMode,
|
|
236
|
+
slot: { width: number; height: number },
|
|
237
|
+
posX: number,
|
|
238
|
+
posY: number,
|
|
239
|
+
): CoverBox | null {
|
|
240
|
+
// Both paths window the SOURCE frame; without its dimensions there is
|
|
241
|
+
// nothing to window — the same guard VideoStage always applied.
|
|
242
|
+
if (!source) return null;
|
|
243
|
+
if (framing && framing.length > 0) {
|
|
244
|
+
const seg = framingWindowAtOutput(framing, spans, outSec);
|
|
245
|
+
if (seg) return contentCoverBox(source, seg.window, slot, seg.bias.x, seg.bias.y);
|
|
246
|
+
}
|
|
247
|
+
// A single-segment content timeline means UNIFORM framing, which ffmpeg
|
|
248
|
+
// already cropped into the mezzanine — cropping again here would trim the
|
|
249
|
+
// same bars twice (the VideoStage guard, kept verbatim in this extraction).
|
|
250
|
+
if (!timeline || timeline.length < 2) return null;
|
|
251
|
+
const rect = contentRectAtOutput(timeline, spans, outSec, source);
|
|
252
|
+
if (rect.full) return null;
|
|
253
|
+
return contentBox(mode, source, rect, slot, posX, posY);
|
|
254
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The face-only jump-cut punch plan (2026-08-16 incident, Task 6).
|
|
3
|
+
*
|
|
4
|
+
* `punch` in render-props is produce's per-span verdict on the cut punch-in:
|
|
5
|
+
* one scale, and a per-span mask saying which spans may render it. It exists
|
|
6
|
+
* because the legacy everywhere-punch scaled screen shares too, and punching
|
|
7
|
+
* a screen share SLIDES its content — text visibly drifting is worse than
|
|
8
|
+
* the jump the punch conceals. The scale itself dropped from the legacy 7%
|
|
9
|
+
* to ~1.5% (user decision 2026-08-16, "minimal, ~1%") for the same reason:
|
|
10
|
+
* on the spans that ARE allowed, a large punch reads as the camera lurching.
|
|
11
|
+
*
|
|
12
|
+
* ABSENT MEANS LEGACY: every pre-feature render-props.json has no `punch`
|
|
13
|
+
* key, and those renders must come out byte-identical to what they always
|
|
14
|
+
* were — the 1.07 punch on every alternating span. Presence is the opt-in.
|
|
15
|
+
*
|
|
16
|
+
* Pure and JSX-free (house rule): the mask/parity interaction is the whole
|
|
17
|
+
* behavior, and it has to be assertable without mounting a Remotion
|
|
18
|
+
* composition.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface PunchPlan {
|
|
22
|
+
/** Scale allowed spans render on their punched turns (e.g. 1.015). */
|
|
23
|
+
scale: number;
|
|
24
|
+
/** Per-span gate, indexed like `spans`; false = never punch this span. */
|
|
25
|
+
allowed: boolean[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether a render-props `punch` field is a plan this renderer will follow —
|
|
30
|
+
* `showWatermark`'s posture (watermark-layout.ts), parse-don't-coerce
|
|
31
|
+
* (CLAUDE.md): the file is user-visible and hand-editable, and a truncated
|
|
32
|
+
* or tweaked plan must fall back to the LEGACY behavior rather than scale
|
|
33
|
+
* spans by `undefined` or a string. Null is the legacy signal the callers
|
|
34
|
+
* already treat "no plan at all" as, so malformed and pre-feature converge
|
|
35
|
+
* on the one path that always worked.
|
|
36
|
+
*/
|
|
37
|
+
export function punchPropsFor(punch: unknown): PunchPlan | null {
|
|
38
|
+
if (typeof punch !== "object" || punch === null) return null;
|
|
39
|
+
const p = punch as { scale?: unknown; allowed?: unknown };
|
|
40
|
+
if (typeof p.scale !== "number" || !Number.isFinite(p.scale) || p.scale <= 0) return null;
|
|
41
|
+
if (!Array.isArray(p.allowed) || p.allowed.some((v) => typeof v !== "boolean")) return null;
|
|
42
|
+
return { scale: p.scale, allowed: p.allowed as boolean[] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The per-span scales EdlVideo renders — the alternating jump-cut concealer,
|
|
47
|
+
* now mask-aware. THE PARITY STILL FLIPS ON EVERY QUALIFYING GAP, masked
|
|
48
|
+
* spans included: the toggle is stable indexing, so adding or removing a
|
|
49
|
+
* span from the mask can never re-phase which of the OTHER spans punch — a
|
|
50
|
+
* masked span simply renders its punched turn at 1 instead of the scale.
|
|
51
|
+
* `allowed[i] !== false` (not `=== true`): a mask shorter than the spans
|
|
52
|
+
* reads as allowed, matching the plan-less legacy default.
|
|
53
|
+
*
|
|
54
|
+
* export-premiere-project.ts (core) replicates this loop for the Premiere
|
|
55
|
+
* export and its doc comment demands lockstep — change one, change both,
|
|
56
|
+
* and both hand-computed tests.
|
|
57
|
+
*/
|
|
58
|
+
export function punchScalesFor(
|
|
59
|
+
spans: readonly { srcIn: number; srcOut: number }[],
|
|
60
|
+
plan: PunchPlan | null,
|
|
61
|
+
punchInScale: number,
|
|
62
|
+
punchThresholdSec: number,
|
|
63
|
+
): number[] {
|
|
64
|
+
const scale = plan ? plan.scale : punchInScale;
|
|
65
|
+
const out: number[] = [];
|
|
66
|
+
let punched = false;
|
|
67
|
+
for (let i = 0; i < spans.length; i++) {
|
|
68
|
+
const prev = spans[i - 1];
|
|
69
|
+
const gap = prev ? spans[i]!.srcIn - prev.srcOut : 0;
|
|
70
|
+
if (i > 0 && gap >= punchThresholdSec) punched = !punched;
|
|
71
|
+
const allowed = plan ? plan.allowed[i] !== false : true;
|
|
72
|
+
out.push(punched && allowed ? scale : 1);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
package/src/stage.ts
CHANGED
|
@@ -141,6 +141,14 @@ export function objectPosYFor(
|
|
|
141
141
|
face: FaceCrop,
|
|
142
142
|
frame: FrameSize = PORTRAIT_FRAME,
|
|
143
143
|
): number {
|
|
144
|
+
// A face that is not the frame's SUBJECT must not steer the crop
|
|
145
|
+
// (2026-08-16 incident: the global face median landed on a screen
|
|
146
|
+
// recording's camera PiP — 12% of frame height, bottom-right — and pinned
|
|
147
|
+
// objectPosY to 1.0, cutting off the top 28% of every full-frame stretch).
|
|
148
|
+
// "screen" means the measured face is incidental: centre the cover instead.
|
|
149
|
+
// Strict equality — absent means "face", so every pre-existing
|
|
150
|
+
// render-props keeps its old behaviour byte-for-byte.
|
|
151
|
+
if (face.subject === "screen") return 0.5;
|
|
144
152
|
const slotH = rect.h * frame.height;
|
|
145
153
|
const displayedH = displayedHeight(rect, face, frame);
|
|
146
154
|
const overflow = displayedH - slotH;
|
|
@@ -254,6 +262,10 @@ export function objectPosXFor(
|
|
|
254
262
|
face: FaceCrop,
|
|
255
263
|
frame: FrameSize = PORTRAIT_FRAME,
|
|
256
264
|
): number {
|
|
265
|
+
// Same subject guard as `objectPosYFor` (2026-08-16 incident): a "screen"
|
|
266
|
+
// frame's incidental face — a camera PiP in a corner — must not drag the
|
|
267
|
+
// horizontal window to that corner. Strict equality; absent means "face".
|
|
268
|
+
if (face.subject === "screen") return 0.5;
|
|
257
269
|
const slotW = rect.w * frame.width;
|
|
258
270
|
const slotH = rect.h * frame.height;
|
|
259
271
|
const displayedW = Math.max(slotW, slotH * (face.sourceAspect ?? frame.width / frame.height));
|
|
@@ -381,6 +393,17 @@ export const COVER_TEXT_RECT: Rect = (() => {
|
|
|
381
393
|
/** Approximate half-height of a caption line block, for free-band math/tests. */
|
|
382
394
|
export const CAPTION_HALF_BAND = 0.045;
|
|
383
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Default caption font size for a frame. Portrait keeps the historical 64px
|
|
398
|
+
* (≈3.3% of a 1080x1920 frame's height); the same 64 in 1920x1080 is 5.9% of
|
|
399
|
+
* frame height and reads as a billboard (user complaint 2026-08-16), so
|
|
400
|
+
* landscape drops to 44 (≈4% of 1080). An explicit `fontSizePx` prop on
|
|
401
|
+
* CaptionTrack still wins over this default.
|
|
402
|
+
*/
|
|
403
|
+
export function captionFontSizeFor(frame: FrameSize): number {
|
|
404
|
+
return frame.width > frame.height ? 44 : 64;
|
|
405
|
+
}
|
|
406
|
+
|
|
384
407
|
/** A vertical interval in frame fractions. */
|
|
385
408
|
export interface Band {
|
|
386
409
|
start: number;
|
|
@@ -504,12 +527,18 @@ export function layoutSlots(
|
|
|
504
527
|
avoidSlicingText(objectPosYFor(rect, face, frame), rect, face, textBands, frame);
|
|
505
528
|
const posX = (rect: Rect) => objectPosXFor(rect, face, frame);
|
|
506
529
|
const landscape = frame.width > frame.height;
|
|
530
|
+
// Landscape anchors sit LOWER than portrait's (user screenshot 2026-08-16:
|
|
531
|
+
// the frame-agnostic anchors below put landscape captions mid-frame — 0.7
|
|
532
|
+
// of a 16:9 frame is chest height, not a lower third). The legal ceiling is
|
|
533
|
+
// 0.835 = 1 − LANDSCAPE_SAFE_AREA.bottom (0.12) − CAPTION_HALF_BAND
|
|
534
|
+
// (0.045); each landscape anchor stays under it. Portrait literals are
|
|
535
|
+
// unchanged.
|
|
507
536
|
switch (layout) {
|
|
508
537
|
case "full-bleed":
|
|
509
538
|
return {
|
|
510
539
|
video: { rect: FULL, cornerRadius: 0, blurPx: 0, dim: 0, opacity: 1, objectPosY: posY(FULL), objectPosX: posX(FULL) },
|
|
511
540
|
graphic: null,
|
|
512
|
-
captionAnchor: 0.7,
|
|
541
|
+
captionAnchor: landscape ? 0.80 : 0.7,
|
|
513
542
|
};
|
|
514
543
|
case "lower-third": {
|
|
515
544
|
// Broadcast lower third: the picture stays whole; the card sits in the
|
|
@@ -565,7 +594,7 @@ export function layoutSlots(
|
|
|
565
594
|
objectPosY: posY(PIP_RECT), objectPosX: posX(PIP_RECT),
|
|
566
595
|
},
|
|
567
596
|
graphic: { x: 0.06, y: 0.14, w: 0.78, h: 0.42 },
|
|
568
|
-
captionAnchor: 0.61,
|
|
597
|
+
captionAnchor: landscape ? 0.78 : 0.61,
|
|
569
598
|
};
|
|
570
599
|
case "graphic-only":
|
|
571
600
|
return {
|
|
@@ -578,13 +607,13 @@ export function layoutSlots(
|
|
|
578
607
|
objectPosY: posY(PIP_RECT), objectPosX: posX(PIP_RECT),
|
|
579
608
|
},
|
|
580
609
|
graphic: { x: 0.04, y: 0.14, w: 0.8, h: 0.54 },
|
|
581
|
-
captionAnchor: 0.73,
|
|
610
|
+
captionAnchor: landscape ? 0.80 : 0.73,
|
|
582
611
|
};
|
|
583
612
|
case "blurred-behind":
|
|
584
613
|
return {
|
|
585
614
|
video: { rect: FULL, cornerRadius: 0, blurPx: 22, dim: 0.55, opacity: 1, objectPosY: posY(FULL), objectPosX: posX(FULL) },
|
|
586
615
|
graphic: { x: 0.07, y: 0.24, w: 0.77, h: 0.36 },
|
|
587
|
-
captionAnchor: 0.69,
|
|
616
|
+
captionAnchor: landscape ? 0.80 : 0.69,
|
|
588
617
|
};
|
|
589
618
|
}
|
|
590
619
|
}
|
|
@@ -732,13 +761,50 @@ export function videoSlotAt(
|
|
|
732
761
|
return target;
|
|
733
762
|
}
|
|
734
763
|
|
|
764
|
+
/**
|
|
765
|
+
* The CSS transform for the user's per-scene crop correction, composed with
|
|
766
|
+
* the idle zoom (multiplicative — VideoStage's §15 comment owns the why).
|
|
767
|
+
*
|
|
768
|
+
* Extracted pure to pin the compose-on-top contract of the render-time
|
|
769
|
+
* framing plan (2026-08-16): VideoStage applies this transform on the wrapper
|
|
770
|
+
* AROUND the framing crop box (`activeCropBox`), and the two are sealed off
|
|
771
|
+
* from each other by construction — this function's inputs are ONLY the zoom
|
|
772
|
+
* and the user's scale/dx/dy, and `activeCropBox` never sees an override. That
|
|
773
|
+
* separation is what makes the editor's "Reset framing" mean "return exactly
|
|
774
|
+
* to auto framing" instead of "return to auto framing as bent by the crop".
|
|
775
|
+
*/
|
|
776
|
+
export function contentTransformFor(
|
|
777
|
+
zoom: number,
|
|
778
|
+
userScale: number,
|
|
779
|
+
userDx: number,
|
|
780
|
+
userDy: number,
|
|
781
|
+
): string | undefined {
|
|
782
|
+
return (
|
|
783
|
+
[
|
|
784
|
+
userDx !== 0 || userDy !== 0 ? `translate(${userDx}px, ${userDy}px)` : "",
|
|
785
|
+
zoom * userScale !== 1 ? `scale(${zoom * userScale})` : "",
|
|
786
|
+
]
|
|
787
|
+
.filter(Boolean)
|
|
788
|
+
.join(" ") || undefined
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
735
792
|
/**
|
|
736
793
|
* Caption anchor at time t. Resolved from the settled layout at the line's
|
|
737
794
|
* start (never animated — a caption sliding mid-word reads as a bug).
|
|
795
|
+
*
|
|
796
|
+
* Frame-aware since 2026-08-16: anchors differ per frame shape, so a 16:9
|
|
797
|
+
* caller must pass its frame rather than inherit the portrait default.
|
|
738
798
|
*/
|
|
739
|
-
export function captionAnchorAt(
|
|
799
|
+
export function captionAnchorAt(
|
|
800
|
+
cues: readonly SceneCue[],
|
|
801
|
+
tSec: number,
|
|
802
|
+
frame: FrameSize = PORTRAIT_FRAME,
|
|
803
|
+
): number {
|
|
740
804
|
const cue = activeCueAt(cues, tSec);
|
|
741
|
-
return cue
|
|
805
|
+
return cue
|
|
806
|
+
? layoutSlots(cue.layout, DEFAULT_FACE, [], frame).captionAnchor
|
|
807
|
+
: layoutSlots("full-bleed", DEFAULT_FACE, [], frame).captionAnchor;
|
|
742
808
|
}
|
|
743
809
|
|
|
744
810
|
function backdropTarget(layout: Layout): number {
|