@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/scenes",
3
- "version": "0.1.23",
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.23"
22
+ "@ossclip/core": "0.1.25"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "react": ">=18",
@@ -1,12 +1,24 @@
1
1
  import React from "react";
2
- import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig } from "remotion";
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
- }> = ({ line, wordOffset, verticalAnchor, fontSizePx, activeColor, ctaKeyword, ctaWindow }) => {
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
- fontFamily:
113
- "'Inter', 'Helvetica Neue', 'Arial Black', Arial, sans-serif",
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: 1.15,
261
+ lineHeight: captionLineHeightFor(direction),
117
262
  textAlign: "center",
118
- color: "white",
119
- WebkitTextStroke: "10px rgba(0,0,0,0.85)",
263
+ color: textColor,
264
+ WebkitTextStroke: `${captionStrokePx(fontSizePx)}px rgba(0,0,0,0.85)`,
120
265
  paintOrder: "stroke fill",
121
- textShadow: "0 4px 24px rgba(0,0,0,0.55)",
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 : "white",
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 = 64,
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={fontSizePx * (active?.captionScale ?? 1)}
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
- const scales = useMemo(() => {
42
- const out: number[] = [];
43
- let punched = false;
44
- for (let i = 0; i < spans.length; i++) {
45
- const prev = spans[i - 1];
46
- const gap = prev ? spans[i]!.srcIn - prev.srcOut : 0;
47
- if (i > 0 && gap >= punchThresholdSec) punched = !punched;
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
 
@@ -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
- contentBox,
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
- const contentTransform =
121
- [
122
- userDx !== 0 || userDy !== 0 ? `translate(${userDx}px, ${userDy}px)` : "",
123
- zoom * userScale !== 1 ? `scale(${zoom * userScale})` : "",
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 passthrough = <div style={{ position: "absolute", inset: 0 }}>{children}</div>;
245
- if (!timeline || timeline.length < 2 || !sourceSize) return passthrough;
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={{
@@ -1,4 +1,9 @@
1
- import { contentRectAt, type ContentRect, type ContentRectSegment } from "@ossclip/core/browser";
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
@@ -5,4 +5,5 @@ export { SceneLayer } from "./SceneLayer";
5
5
  export { Watermark } from "./Watermark";
6
6
  export * from "./stage";
7
7
  export * from "./watermark-layout";
8
+ export * from "./punch-plan";
8
9
  export * from "./caption-visibility";
@@ -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(cues: readonly SceneCue[], tSec: number): number {
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 ? layoutSlots(cue.layout).captionAnchor : layoutSlots("full-bleed").captionAnchor;
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 {