@ossclip/scenes 0.1.5 → 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 +2 -2
- package/src/CaptionTrack.tsx +15 -4
- package/src/SceneLayer.tsx +71 -29
- package/src/motion.ts +44 -0
- package/src/source-fit.ts +143 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossclip/scenes",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
22
|
+
"@ossclip/core": "0.1.7"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"react": ">=18",
|
package/src/CaptionTrack.tsx
CHANGED
|
@@ -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
|
|
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:
|
|
126
|
-
|
|
127
|
-
|
|
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
|
package/src/SceneLayer.tsx
CHANGED
|
@@ -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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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 /
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
/**
|