@ossclip/scenes 0.1.6 → 0.1.7

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.6",
3
+ "version": "0.1.7",
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.6"
22
+ "@ossclip/core": "0.1.7"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "react": ">=18",
@@ -4,6 +4,7 @@ import type { CaptionLine, SceneCue } from "@ossclip/core/browser";
4
4
  import { safeAreaFor, activeCueAt } from "./stage";
5
5
  import { frameWindow } from "./frames";
6
6
  import { captionAnchorAvoiding, regionsDuring, type OccupiedRegion } from "./source-fit";
7
+ import { CAPTION_POP_SEC, easeOutQuad } from "./motion";
7
8
 
8
9
  export interface CaptionTrackProps {
9
10
  lines: CaptionLine[];
@@ -107,7 +108,15 @@ const LineView: React.FC<{
107
108
  }}
108
109
  >
109
110
  {line.words.map((w, i) => {
110
- const active = t >= w.start && t <= Math.max(w.end, w.start + 0.12);
111
+ const held = Math.max(w.end, w.start + 0.12);
112
+ const inWindow = t >= w.start && t <= held;
113
+ // Ramp from the word's OWN start, then hold — frame-driven, because
114
+ // the CSS transition this replaces only ever animated in the
115
+ // editor's real-time <Player>; the render seeks and screenshots,
116
+ // so no wall-clock time passes and the scale snapped (spec
117
+ // 2026-08-04). Same ease as the layer's entrance and exit.
118
+ const p = inWindow ? Math.min(1, (t - w.start) / CAPTION_POP_SEC) : 0;
119
+ const pop = easeOutQuad(p);
111
120
  return (
112
121
  <span
113
122
  key={i}
@@ -122,9 +131,11 @@ const LineView: React.FC<{
122
131
  // parent layer stays pointer-events: none); harmless in the
123
132
  // render, where nothing dispatches events.
124
133
  pointerEvents: "auto",
125
- transform: active ? "scale(1.08)" : "scale(1)",
126
- color: active ? activeColor : "white",
127
- transition: "transform 60ms linear",
134
+ transform: pop > 0 ? `scale(${1 + 0.08 * pop})` : "scale(1)",
135
+ // Colour stays keyed to the window, not the ramp: colour has
136
+ // no in-between worth animating, and lerping it would fight
137
+ // the stroke.
138
+ color: inWindow ? activeColor : "white",
128
139
  }}
129
140
  >
130
141
  {/* Per WORD, not per line: a line straddling the cue boundary
@@ -14,6 +14,7 @@ import { ChatMock } from "./components/ChatMock";
14
14
  import { ScreenshotFrame } from "./components/ScreenshotFrame";
15
15
  import { BulletList } from "./components/BulletList";
16
16
  import { compensateEdits, type ElementEdits } from "./editable";
17
+ import { easeOutQuad, entranceExitSec } from "./motion";
17
18
 
18
19
  /* eslint-disable @typescript-eslint/no-explicit-any -- props are registry-validated upstream */
19
20
  const COMPONENTS: Record<
@@ -31,11 +32,6 @@ const COMPONENTS: Record<
31
32
  BulletList,
32
33
  };
33
34
 
34
- /** Seconds a graphic spends leaving. Matches LAYOUT_TRANSITION_SEC's order of
35
- * magnitude so the graphic departs WITH the video slot's morph — the reported
36
- * failure was the split view closing first and the card then blinking out. */
37
- const EXIT_SEC = 0.3;
38
-
39
35
  /**
40
36
  * Layouts whose graphic slot sits over LIVE video (R20 §94). Everywhere else
41
37
  * the graphic lands on the stage background and the theme guarantees its
@@ -57,35 +53,86 @@ const scrimColor = (themeBg: string): string => {
57
53
  };
58
54
 
59
55
  /**
60
- * Uniform exit for every graphic (R16 §69). Components own their ENTRANCES
61
- * (staggered rises, per element); the exit lives here at the layer because it
62
- * is the cue's END doing the animating, and every component leaving the same
63
- * way is what makes the cut read as designed. Inside the cue's Sequence, so
64
- * frame 0 is the cue's own start.
56
+ * Uniform EXIT for every graphic, and an entrance for the SCRIM alone —
57
+ * both at the layer (R16 §69).
58
+ *
59
+ * Components own their content entrances: all nine stagger their elements
60
+ * in through anim.ts's useEnter springs, a fact this file stated correctly
61
+ * for a year, then briefly contradicted when a survey missed the springs
62
+ * and a layer-wide entrance double-animated everything. What never animated
63
+ * was the over-video scrim (R21 §100), which appeared at full opacity on
64
+ * the cue's first frame: "a half black box appears" (spec 2026-08-04). So
65
+ * the entrance here is the scrim's, and only the scrim's.
66
+ *
67
+ * The exit stays layer-wide: it is the cue's END doing the animating, and
68
+ * every component leaving the same way is what makes the cut read as
69
+ * designed. Both ends read their seconds from entranceExitSec, which
70
+ * shrinks the pair together on a cue too short to hold both — the scrim
71
+ * and the exit, that is; the components' content springs (anim.ts) predate
72
+ * the resolver and do not read it, so a long-staggered component can still
73
+ * overlap the exit on a short cue (see the spec's 'Neither end may eat the
74
+ * other' correction). Inside the cue's Sequence, so local frame 0 is the
75
+ * cue's own start.
65
76
  */
77
+ const wrapperStyle = (ease: number): React.CSSProperties => ({
78
+ width: "100%",
79
+ height: "100%",
80
+ display: "flex",
81
+ alignItems: "center",
82
+ justifyContent: "center",
83
+ opacity: ease,
84
+ // 18px over the exit's ~9 frames. Eased, so the peak step is 3.78px on the
85
+ // first frame, tapering below 0.25px — small enough to read smooth at 30fps
86
+ // without blur, which was the actual ask. (Used only by ExitFade since the
87
+ // entrance was scoped to the scrim; see ./motion for the seconds.)
88
+ transform: ease < 1 ? `translateY(${(1 - ease) * 18}px)` : undefined,
89
+ });
90
+
66
91
  const ExitFade: React.FC<{ durationInFrames: number; children: React.ReactNode }> = ({
67
92
  durationInFrames,
68
93
  children,
69
94
  }) => {
70
95
  const frame = useCurrentFrame();
71
96
  const { fps } = useVideoConfig();
97
+ const { exitSec } = entranceExitSec(durationInFrames / fps);
72
98
  const remaining = (durationInFrames - frame) / fps;
73
- const p = Math.min(1, Math.max(0, remaining / EXIT_SEC));
74
- const ease = p * (2 - p);
99
+ const p = exitSec <= 0 ? 1 : Math.min(1, Math.max(0, remaining / exitSec));
100
+ return <div style={wrapperStyle(easeOutQuad(p))}>{children}</div>;
101
+ };
102
+
103
+ /**
104
+ * The scrim, carrying its own entrance. One div, deliberately: an ancestor
105
+ * wrapper with opacity < 1 forms a Backdrop Root, which empties the
106
+ * backdrop-filter's input — the band rendered as flat tint for the whole
107
+ * entrance and the blur snapped on when the ease hit 1. Opacity on the
108
+ * element itself composites the blurred band at partial alpha instead, so
109
+ * the frost fades in WITH the tint. Positioned absolute so it stays out of
110
+ * ExitFade's flex flow; painted before the content div in tree order, so
111
+ * content keeps painting above it (R21 §100).
112
+ */
113
+ const Scrim: React.FC<{ durationInFrames: number; themeBg: string; radiusPx: number }> = ({
114
+ durationInFrames,
115
+ themeBg,
116
+ radiusPx,
117
+ }) => {
118
+ const frame = useCurrentFrame();
119
+ const { fps } = useVideoConfig();
120
+ const { enterSec } = entranceExitSec(durationInFrames / fps);
121
+ const p = enterSec <= 0 ? 1 : Math.min(1, Math.max(0, frame / fps / enterSec));
122
+ const ease = easeOutQuad(p);
75
123
  return (
76
124
  <div
77
125
  style={{
78
- width: "100%",
79
- height: "100%",
80
- display: "flex",
81
- alignItems: "center",
82
- justifyContent: "center",
126
+ position: "absolute",
127
+ inset: 0,
128
+ background: scrimColor(themeBg),
129
+ backdropFilter: "blur(14px)",
130
+ WebkitBackdropFilter: "blur(14px)",
131
+ borderRadius: radiusPx,
83
132
  opacity: ease,
84
133
  transform: ease < 1 ? `translateY(${(1 - ease) * 18}px)` : undefined,
85
134
  }}
86
- >
87
- {children}
88
- </div>
135
+ />
89
136
  );
90
137
  };
91
138
 
@@ -151,15 +198,10 @@ export const SceneLayer: React.FC<{ cues: SceneCue[]; theme: Theme }> = ({ cues,
151
198
  >
152
199
  <ExitFade durationInFrames={durationInFrames}>
153
200
  {overVideo ? (
154
- <div
155
- style={{
156
- position: "absolute",
157
- inset: 0,
158
- background: scrimColor(theme.bg),
159
- backdropFilter: "blur(14px)",
160
- WebkitBackdropFilter: "blur(14px)",
161
- borderRadius: theme.radiusPx,
162
- }}
201
+ <Scrim
202
+ durationInFrames={durationInFrames}
203
+ themeBg={theme.bg}
204
+ radiusPx={theme.radiusPx}
163
205
  />
164
206
  ) : null}
165
207
  {/* position:relative so the content paints (and hit-tests)
package/src/motion.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The layer's motion constants and the one curve every animated thing in the
3
+ * render shares. One module rather than per-file literals so the entrance,
4
+ * the exit, and the caption pop cannot drift onto different curves — the
5
+ * design's "reads as designed" claim (R16 §69) depends on them agreeing.
6
+ */
7
+
8
+ /** Seconds a graphic spends arriving. Mirrors EXIT_SEC — one number, both ends. */
9
+ export const ENTER_SEC = 0.3;
10
+
11
+ /** Seconds a graphic spends leaving. Matches LAYOUT_TRANSITION_SEC's order of
12
+ * magnitude so the graphic departs WITH the video slot's morph — the reported
13
+ * failure was the split view closing first and the card then blinking out. */
14
+ export const EXIT_SEC = 0.3;
15
+
16
+ /**
17
+ * Seconds the caption's active word takes to reach its 1.08 emphasis. Four
18
+ * frames at 30fps: the original CSS transition said 60ms, which is 1.8
19
+ * frames — honouring it exactly would still read as a step.
20
+ */
21
+ export const CAPTION_POP_SEC = 0.133;
22
+
23
+ /** The exit's existing ease — fast start, soft landing. */
24
+ export const easeOutQuad = (p: number): number => p * (2 - p);
25
+
26
+ /**
27
+ * The entrance and exit seconds for a cue, shrunk proportionally when the cue
28
+ * is too short to hold both. Resolved together rather than clamped
29
+ * independently: two independent clamps can still sum past the duration, and
30
+ * the failure that produces — entrance and exit overlapping, their opacities
31
+ * multiplying into a dip halfway through a graphic's life — is invisible in
32
+ * a still and obvious in motion.
33
+ */
34
+ export function entranceExitSec(
35
+ durationSec: number,
36
+ enterSec: number = ENTER_SEC,
37
+ exitSec: number = EXIT_SEC,
38
+ ): { enterSec: number; exitSec: number } {
39
+ if (durationSec <= 0) return { enterSec: 0, exitSec: 0 };
40
+ const total = enterSec + exitSec;
41
+ if (total <= durationSec) return { enterSec, exitSec };
42
+ const k = durationSec / total;
43
+ return { enterSec: enterSec * k, exitSec: exitSec * k };
44
+ }
package/src/source-fit.ts CHANGED
@@ -2,7 +2,6 @@ import { SCENE_REGISTRY, type Layout, type SceneCue } from "@ossclip/core/browse
2
2
  import {
3
3
  CAPTION_HALF_BAND,
4
4
  PORTRAIT_FRAME,
5
- SAFE_AREA,
6
5
  freeBands,
7
6
  layoutSlots,
8
7
  safeAreaFor,
@@ -63,6 +62,45 @@ export function overlapFraction(
63
62
  return Math.min(1, covered / rect.h);
64
63
  }
65
64
 
65
+ /**
66
+ * The video rect a routed graphic must stay clear of, or null when this
67
+ * layout intends the graphic to sit on the picture (R27 §120).
68
+ *
69
+ * Three clauses, all read from the slot table, none naming a layout. Deriving
70
+ * rather than listing is load-bearing twice: §120's own list of three missed
71
+ * `pip-bubble`, whose fully visible bubble sits 0.1 below its graphic; and
72
+ * clause 2's "in THIS frame" is what sends the landscape splits — which
73
+ * separate by X, with a full-height video — to clause 3 instead of skipping
74
+ * every scene in 16:9.
75
+ *
76
+ * No startSec/endSec: those exist on OccupiedRegion because burned-in titles
77
+ * are transient (§32) and the video slot is not. Absent already means
78
+ * "always" to `regionsDuring`.
79
+ */
80
+ export function videoObstacleFor(
81
+ layout: Layout,
82
+ frame: FrameSize = PORTRAIT_FRAME,
83
+ ): OccupiedRegion | null {
84
+ const slots = layoutSlots(layout, undefined, [], frame);
85
+ // Clause 0 — no graphic slot, so there is nothing to keep clear of anything.
86
+ // NOT "this layout never reaches the placer": full-bleed is skipped as a
87
+ // CANDIDATE for having no slot, then borrows the default layout's geometry
88
+ // and reaches the placer anyway. Answering null here is what stops it being
89
+ // constrained by a video band that is not on its screen (§120).
90
+ if (!slots.graphic) return null;
91
+ // Clause 1 — graphic-only parks the pip rect at zero opacity.
92
+ if (slots.video.opacity === 0) return null;
93
+ const g = slots.graphic;
94
+ const v = slots.video.rect;
95
+ const overlap = Math.min(g.y + g.h, v.y + v.h) - Math.max(g.y, v.y);
96
+ // Clause 3 — they already share vertical space, so the layout means it.
97
+ // `> 0` rather than `>= 0`: touching edges count as clear, matching the
98
+ // `toBeLessThanOrEqual(0)` the §120 test asserts.
99
+ if (overlap > 0) return null;
100
+ // Clause 2 — authored apart, so routing must keep them apart.
101
+ return { y: v.y, h: v.h };
102
+ }
103
+
66
104
  /**
67
105
  * Slide a graphic rect into the tallest band that the source's text leaves
68
106
  * free, keeping its size. Returns null when no free band can hold it — the
@@ -71,8 +109,27 @@ export function overlapFraction(
71
109
  export function placeInFreeBand(
72
110
  rect: { x: number; y: number; w: number; h: number },
73
111
  regions: readonly OccupiedRegion[],
112
+ /**
113
+ * The picture, when this layout authored the graphic clear of it (§120).
114
+ * Optional and defaulted so every existing caller keeps its behaviour;
115
+ * `videoObstacleFor` returns null for the layouts that intend the overlap.
116
+ */
117
+ videoObstacle?: OccupiedRegion | null,
118
+ /**
119
+ * The output frame. The band search is the other frame-dependent geometry in
120
+ * routing (R15): landscape has no platform chrome to dodge, so its insets are
121
+ * 0.06/0.12 against portrait's 0.12/0.22. Searching the portrait safe area in
122
+ * a 16:9 run refuses legal area — a band at [0.72, 0.88] is 0.16 tall and
123
+ * free, and portrait's floor of 0.78 hides all but 0.06 of it. Optional and
124
+ * portrait-defaulted so every existing caller keeps its behaviour.
125
+ */
126
+ frame: FrameSize = PORTRAIT_FRAME,
74
127
  ): { x: number; y: number; w: number; h: number } | null {
75
- const [tallest] = freeBands({ start: SAFE_AREA.top, end: 1 - SAFE_AREA.bottom }, regions);
128
+ // freeBands merges overlapping blocked rects itself, so the obstacle can
129
+ // simply join the text regions rather than needing to be reconciled.
130
+ const blocked = videoObstacle ? [...regions, videoObstacle] : regions;
131
+ const safe = safeAreaFor(frame);
132
+ const [tallest] = freeBands({ start: safe.top, end: 1 - safe.bottom }, blocked);
76
133
  if (!tallest) return null;
77
134
  const bandHeight = tallest.end - tallest.start;
78
135
  if (bandHeight < MIN_ROUTED_SLOT_H) return null;
@@ -101,6 +158,13 @@ export interface SourceTextPlan {
101
158
  relayouts: Array<{ id: string; from: Layout; to: Layout }>;
102
159
  /** Scenes whose graphic was repositioned into a free band. */
103
160
  moved: Array<{ id: string; y: number; h: number }>;
161
+ /**
162
+ * Scenes moved to a layout that INTENDS a graphic over the picture, because
163
+ * no band was clear of both the source's text and the video (§120). Kept
164
+ * separate from `relayouts` because the reason differs, and a run that
165
+ * quietly changes a scene's visual character should say which happened.
166
+ */
167
+ overlaid: Array<{ id: string; from: Layout; to: Layout }>;
104
168
  /** Scenes dropped because no layout had a free slot. */
105
169
  skipped: Array<{ id: string; reason: string }>;
106
170
  }
@@ -113,13 +177,20 @@ export interface SourceTextPlan {
113
177
  export function routeAroundSourceText(
114
178
  cues: readonly SceneCue[],
115
179
  regions: readonly OccupiedRegion[],
180
+ /**
181
+ * The output frame. Portrait by default so every existing caller keeps its
182
+ * behaviour — but a 16:9 run MUST pass its own, because the splits separate
183
+ * by X there and the slot table answers differently (§120).
184
+ */
185
+ frame: FrameSize = PORTRAIT_FRAME,
116
186
  ): SourceTextPlan {
117
187
  if (regions.length === 0) {
118
- return { cues: [...cues], relayouts: [], moved: [], skipped: [] };
188
+ return { cues: [...cues], relayouts: [], moved: [], overlaid: [], skipped: [] };
119
189
  }
120
190
  const out: SceneCue[] = [];
121
191
  const relayouts: SourceTextPlan["relayouts"] = [];
122
192
  const moved: SourceTextPlan["moved"] = [];
193
+ const overlaid: SourceTextPlan["overlaid"] = [];
123
194
  const skipped: SourceTextPlan["skipped"] = [];
124
195
 
125
196
  for (const cue of cues) {
@@ -145,7 +216,7 @@ export function routeAroundSourceText(
145
216
  ];
146
217
  let placed: Layout | null = null;
147
218
  for (const layout of candidates) {
148
- const slot = layoutSlots(layout).graphic;
219
+ const slot = layoutSlots(layout, undefined, [], frame).graphic;
149
220
  if (!slot) continue;
150
221
  if (overlapFraction(slot, active) <= MAX_GRAPHIC_OVERLAP) {
151
222
  placed = layout;
@@ -161,16 +232,80 @@ export function routeAroundSourceText(
161
232
  // No layout is clear where it stands — so move the slot instead of losing
162
233
  // the scene. "Route around them, or skip" is the rule, and routing comes
163
234
  // first: the graphic keeps its size and slides into the largest free band.
164
- const base = layoutSlots(cue.layout).graphic ?? layoutSlots(meta.defaultLayout).graphic;
165
- const shifted = base ? placeInFreeBand(base, active) : null;
235
+ //
236
+ // The SLOT may be borrowed — `full-bleed` has none, so the default layout
237
+ // donates its geometry — but the OBSTACLE must come from `cue.layout`, the
238
+ // layout that actually renders. This path only moves the rect; it never
239
+ // changes the layout, so asking the donor hands the placer a video band
240
+ // that is not on screen. A full-bleed cue would dodge pip-bubble's bubble
241
+ // and be skipped "because of the video" — in a layout §120's own rule
242
+ // classifies as intending the overlap, and for which the obstacle is null.
243
+ const baseLayout = layoutSlots(cue.layout, undefined, [], frame).graphic
244
+ ? cue.layout
245
+ : meta.defaultLayout;
246
+ const base = layoutSlots(baseLayout, undefined, [], frame).graphic;
247
+ const shifted = base
248
+ ? placeInFreeBand(base, active, videoObstacleFor(cue.layout, frame), frame)
249
+ : null;
166
250
  if (shifted) {
167
251
  moved.push({ id: cue.id, y: shifted.y, h: shifted.h });
168
252
  out.push({ ...cue, graphicRect: shifted });
169
253
  continue;
170
254
  }
171
- skipped.push({ id: cue.id, reason: "source already has on-screen text here" });
255
+
256
+ // Adding the video as an obstacle strictly shrinks the free space, so
257
+ // strictly more scenes would reach the skip below than before §120 — and
258
+ // R25 §118 shipped under-delivery accounting because missing graphics are
259
+ // a known pain. Before losing the scene, try the layouts this component
260
+ // ALREADY declares that intend a graphic over the picture.
261
+ //
262
+ // Note this is not the candidate loop again: there, an alternate had to be
263
+ // clear where it was authored. Here the graphic is free to slide within
264
+ // the alternate, because clause 3 means its video is not an obstacle —
265
+ // strictly more room, which is why it can succeed where the step above
266
+ // failed.
267
+ //
268
+ // Drawn from the registry's altLayouts rather than a global list of
269
+ // overlay layouts: seven systems are keyed to the closed component enum
270
+ // and four fail SILENTLY on an unknown value, so routing must not invent
271
+ // a placement the registry has not blessed.
272
+ let overlay: { layout: Layout; rect: { x: number; y: number; w: number; h: number } } | null =
273
+ null;
274
+ for (const alt of meta.altLayouts ?? []) {
275
+ if (videoObstacleFor(alt, frame) !== null) continue;
276
+ const slot = layoutSlots(alt, undefined, [], frame).graphic;
277
+ if (!slot) continue;
278
+ const moved2 = placeInFreeBand(slot, active, null, frame);
279
+ if (moved2) {
280
+ overlay = { layout: alt, rect: moved2 };
281
+ break;
282
+ }
283
+ }
284
+ if (overlay) {
285
+ overlaid.push({ id: cue.id, from: cue.layout, to: overlay.layout });
286
+ out.push({ ...cue, layout: overlay.layout, graphicRect: overlay.rect });
287
+ continue;
288
+ }
289
+
290
+ // Two different failures land here now, and naming the wrong one
291
+ // undercuts the §118 accounting this path exists to serve: the text may
292
+ // have covered everything, or it may have left a band that only the
293
+ // VIDEO made too small to use. Retrying without the obstacle is the
294
+ // cheapest way to ask which, and it runs only on the skip path.
295
+ // Same frame as the placement it is second-guessing. Portrait's safe
296
+ // window is a strict subset of landscape's, so a portrait-hardcoded
297
+ // diagnostic in a 16:9 run would find nothing free where the placement
298
+ // had looked wider — and wrongly blame the TEXT for a drop the video
299
+ // caused (R15).
300
+ const clearOfTextAlone = base ? placeInFreeBand(base, active, null, frame) !== null : false;
301
+ skipped.push({
302
+ id: cue.id,
303
+ reason: clearOfTextAlone
304
+ ? "no band clear of both the source's text and the video"
305
+ : "source already has on-screen text here",
306
+ });
172
307
  }
173
- return { cues: out, relayouts, moved, skipped };
308
+ return { cues: out, relayouts, moved, overlaid, skipped };
174
309
  }
175
310
 
176
311
  /**