@ossclip/scenes 0.1.0
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/LICENSE +27 -0
- package/README.md +20 -0
- package/package.json +38 -0
- package/src/CaptionTrack.tsx +209 -0
- package/src/EdlVideo.tsx +87 -0
- package/src/SceneLayer.tsx +184 -0
- package/src/VideoStage.tsx +264 -0
- package/src/anim.ts +26 -0
- package/src/components/BulletList.tsx +91 -0
- package/src/components/ChatMock.tsx +96 -0
- package/src/components/FlowDiagram.tsx +138 -0
- package/src/components/RuleCard.tsx +86 -0
- package/src/components/ScreenshotFrame.tsx +94 -0
- package/src/components/StatCard.tsx +90 -0
- package/src/components/StrikethroughReveal.tsx +130 -0
- package/src/components/TerminalMock.tsx +105 -0
- package/src/components/TitleCard.tsx +95 -0
- package/src/content-crop.ts +179 -0
- package/src/editable.ts +51 -0
- package/src/fit.ts +536 -0
- package/src/geometry.ts +8 -0
- package/src/index.ts +5 -0
- package/src/source-fit.ts +218 -0
- package/src/stage.ts +764 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { SCENE_REGISTRY, type Layout, type SceneCue } from "@ossclip/core/browser";
|
|
2
|
+
import {
|
|
3
|
+
CAPTION_HALF_BAND,
|
|
4
|
+
PORTRAIT_FRAME,
|
|
5
|
+
SAFE_AREA,
|
|
6
|
+
freeBands,
|
|
7
|
+
layoutSlots,
|
|
8
|
+
safeAreaFor,
|
|
9
|
+
type FrameSize,
|
|
10
|
+
} from "./stage";
|
|
11
|
+
|
|
12
|
+
/** Occupancy rect reported by the source-text scan (core's `TextRegion`). */
|
|
13
|
+
export interface OccupiedRegion {
|
|
14
|
+
y: number;
|
|
15
|
+
h: number;
|
|
16
|
+
/**
|
|
17
|
+
* When the region is on screen, in SOURCE seconds. Burned-in titles are
|
|
18
|
+
* transient — treating them as permanent was what made the first detector
|
|
19
|
+
* reject them outright (FINDINGS §32) — so a region only conflicts with
|
|
20
|
+
* scenes that share its window. Absent means "always" (assumed regions).
|
|
21
|
+
*/
|
|
22
|
+
startSec?: number;
|
|
23
|
+
endSec?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The regions actually on screen during [startSec, endSec]. */
|
|
27
|
+
export function regionsDuring(
|
|
28
|
+
regions: readonly OccupiedRegion[],
|
|
29
|
+
startSec: number,
|
|
30
|
+
endSec: number,
|
|
31
|
+
): OccupiedRegion[] {
|
|
32
|
+
return regions.filter(
|
|
33
|
+
(r) =>
|
|
34
|
+
(r.startSec ?? Number.NEGATIVE_INFINITY) < endSec &&
|
|
35
|
+
(r.endSec ?? Number.POSITIVE_INFINITY) > startSec,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Routing around a source's own burned-in text (FINDINGS §26).
|
|
41
|
+
*
|
|
42
|
+
* The rule is asymmetric on purpose. ossclip's graphics must not overlap
|
|
43
|
+
* existing on-screen elements at all — two titles competing, one of them
|
|
44
|
+
* clipped, is worse than one title. Captions are different: they are the
|
|
45
|
+
* accessibility layer and must ALWAYS be present, so they move rather than
|
|
46
|
+
* disappear, and only fall back to their layout anchor if nothing is free.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** Above this share of a slot covered by source text, the slot is unusable. */
|
|
50
|
+
const MAX_GRAPHIC_OVERLAP = 0.12;
|
|
51
|
+
|
|
52
|
+
export function overlapFraction(
|
|
53
|
+
rect: { y: number; h: number },
|
|
54
|
+
regions: readonly OccupiedRegion[],
|
|
55
|
+
): number {
|
|
56
|
+
if (rect.h <= 0) return 0;
|
|
57
|
+
let covered = 0;
|
|
58
|
+
for (const r of regions) {
|
|
59
|
+
const top = Math.max(rect.y, r.y);
|
|
60
|
+
const bottom = Math.min(rect.y + rect.h, r.y + r.h);
|
|
61
|
+
if (bottom > top) covered += bottom - top;
|
|
62
|
+
}
|
|
63
|
+
return Math.min(1, covered / rect.h);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Slide a graphic rect into the tallest band that the source's text leaves
|
|
68
|
+
* free, keeping its size. Returns null when no free band can hold it — the
|
|
69
|
+
* only case where a scene is genuinely skipped.
|
|
70
|
+
*/
|
|
71
|
+
export function placeInFreeBand(
|
|
72
|
+
rect: { x: number; y: number; w: number; h: number },
|
|
73
|
+
regions: readonly OccupiedRegion[],
|
|
74
|
+
): { x: number; y: number; w: number; h: number } | null {
|
|
75
|
+
const [tallest] = freeBands({ start: SAFE_AREA.top, end: 1 - SAFE_AREA.bottom }, regions);
|
|
76
|
+
if (!tallest) return null;
|
|
77
|
+
const bandHeight = tallest.end - tallest.start;
|
|
78
|
+
if (bandHeight < MIN_ROUTED_SLOT_H) return null;
|
|
79
|
+
// Reserve room for captions inside the band before the graphic takes it.
|
|
80
|
+
// Captions are mandatory and the graphic is not, so the graphic is what
|
|
81
|
+
// yields — otherwise a routed graphic swallows the only free band and the
|
|
82
|
+
// captions fall back on top of the source's own text.
|
|
83
|
+
const reserved = CAPTION_BAND_H;
|
|
84
|
+
const usable = bandHeight - reserved >= MIN_ROUTED_SLOT_H ? bandHeight - reserved : bandHeight;
|
|
85
|
+
// Shrink to the band when the slot does not fit. A smaller graphic beats no
|
|
86
|
+
// graphic, and since §23 every component scales its type to whatever slot it
|
|
87
|
+
// is handed — so a shorter slot renders correctly rather than overflowing.
|
|
88
|
+
const h = Math.min(rect.h, usable);
|
|
89
|
+
return { ...rect, y: tallest.start + (usable - h) / 2, h };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Vertical room a caption line needs, with a little breathing space. */
|
|
93
|
+
const CAPTION_BAND_H = CAPTION_HALF_BAND * 2 + 0.02;
|
|
94
|
+
|
|
95
|
+
/** Below this a routed graphic is too small to read — skip the scene instead. */
|
|
96
|
+
const MIN_ROUTED_SLOT_H = 0.14;
|
|
97
|
+
|
|
98
|
+
export interface SourceTextPlan {
|
|
99
|
+
cues: SceneCue[];
|
|
100
|
+
/** Scenes moved to a layout whose slot is clear. */
|
|
101
|
+
relayouts: Array<{ id: string; from: Layout; to: Layout }>;
|
|
102
|
+
/** Scenes whose graphic was repositioned into a free band. */
|
|
103
|
+
moved: Array<{ id: string; y: number; h: number }>;
|
|
104
|
+
/** Scenes dropped because no layout had a free slot. */
|
|
105
|
+
skipped: Array<{ id: string; reason: string }>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Re-place or drop each cue so no graphic lands on the source's own text.
|
|
110
|
+
* Tries the cue's layout first, then the component's alternates; a component
|
|
111
|
+
* with nowhere free is skipped rather than stacked.
|
|
112
|
+
*/
|
|
113
|
+
export function routeAroundSourceText(
|
|
114
|
+
cues: readonly SceneCue[],
|
|
115
|
+
regions: readonly OccupiedRegion[],
|
|
116
|
+
): SourceTextPlan {
|
|
117
|
+
if (regions.length === 0) {
|
|
118
|
+
return { cues: [...cues], relayouts: [], moved: [], skipped: [] };
|
|
119
|
+
}
|
|
120
|
+
const out: SceneCue[] = [];
|
|
121
|
+
const relayouts: SourceTextPlan["relayouts"] = [];
|
|
122
|
+
const moved: SourceTextPlan["moved"] = [];
|
|
123
|
+
const skipped: SourceTextPlan["skipped"] = [];
|
|
124
|
+
|
|
125
|
+
for (const cue of cues) {
|
|
126
|
+
// A cue with no graphic (a plain take) has nothing to route.
|
|
127
|
+
if (cue.component === undefined) {
|
|
128
|
+
out.push(cue);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// Only the text actually up while this scene is on screen can conflict.
|
|
132
|
+
const active = regionsDuring(regions, cue.startSec, cue.endSec);
|
|
133
|
+
if (active.length === 0) {
|
|
134
|
+
out.push(cue);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const meta = SCENE_REGISTRY[cue.component];
|
|
138
|
+
// The cue's own layout first (§20's variety pass may have moved it off the
|
|
139
|
+
// default), then the default, then the alternates — every placement the
|
|
140
|
+
// component is known to render correctly in.
|
|
141
|
+
const candidates: Layout[] = [
|
|
142
|
+
...new Set<Layout>([cue.layout, meta?.defaultLayout, ...(meta?.altLayouts ?? [])].filter(
|
|
143
|
+
Boolean,
|
|
144
|
+
) as Layout[]),
|
|
145
|
+
];
|
|
146
|
+
let placed: Layout | null = null;
|
|
147
|
+
for (const layout of candidates) {
|
|
148
|
+
const slot = layoutSlots(layout).graphic;
|
|
149
|
+
if (!slot) continue;
|
|
150
|
+
if (overlapFraction(slot, active) <= MAX_GRAPHIC_OVERLAP) {
|
|
151
|
+
placed = layout;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (placed !== null) {
|
|
156
|
+
if (placed !== cue.layout) relayouts.push({ id: cue.id, from: cue.layout, to: placed });
|
|
157
|
+
out.push(placed === cue.layout ? cue : { ...cue, layout: placed });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// No layout is clear where it stands — so move the slot instead of losing
|
|
162
|
+
// the scene. "Route around them, or skip" is the rule, and routing comes
|
|
163
|
+
// 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;
|
|
166
|
+
if (shifted) {
|
|
167
|
+
moved.push({ id: cue.id, y: shifted.y, h: shifted.h });
|
|
168
|
+
out.push({ ...cue, graphicRect: shifted });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
skipped.push({ id: cue.id, reason: "source already has on-screen text here" });
|
|
172
|
+
}
|
|
173
|
+
return { cues: out, relayouts, moved, skipped };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A caption anchor clear of the source's text, the active graphic and the
|
|
178
|
+
* platform chrome — searched outward from the layout's own anchor so captions
|
|
179
|
+
* stay where the design put them whenever that is free.
|
|
180
|
+
*
|
|
181
|
+
* Never returns null: a caption that cannot find a clear band still renders at
|
|
182
|
+
* its layout anchor, because a missing caption is a worse failure than a
|
|
183
|
+
* crowded one (BRAINSTORM §4.5, "muted-viewing complete").
|
|
184
|
+
*/
|
|
185
|
+
export function captionAnchorAvoiding(
|
|
186
|
+
layout: Layout,
|
|
187
|
+
regions: readonly OccupiedRegion[],
|
|
188
|
+
/** The cue's actual graphic rect, which routing may have moved (§26). */
|
|
189
|
+
graphic?: { y: number; h: number } | null,
|
|
190
|
+
/** The output frame — anchors and safe margins follow its shape (R15). */
|
|
191
|
+
frame: FrameSize = PORTRAIT_FRAME,
|
|
192
|
+
): number {
|
|
193
|
+
const slots = layoutSlots(layout, undefined, [], frame);
|
|
194
|
+
const safe = safeAreaFor(frame);
|
|
195
|
+
const base = slots.captionAnchor;
|
|
196
|
+
// NO empty-regions early-out (R11 Task 2b): a hand-moved graphic rect must
|
|
197
|
+
// be able to move the anchor on a CLEAN source too. When the rect equals
|
|
198
|
+
// the layout default this stays the base anchor — every layout's default
|
|
199
|
+
// anchor is clear of its own slot (the stage.test.ts invariant) — so the
|
|
200
|
+
// behaviour change is confined to rects that actually moved.
|
|
201
|
+
|
|
202
|
+
const clear = (anchor: number): boolean => {
|
|
203
|
+
const band = { y: anchor - CAPTION_HALF_BAND, h: CAPTION_HALF_BAND * 2 };
|
|
204
|
+
if (band.y < safe.top || band.y + band.h > 1 - safe.bottom) return false;
|
|
205
|
+
if (overlapFraction(band, regions) > 0) return false;
|
|
206
|
+
const g = graphic ?? slots.graphic;
|
|
207
|
+
if (g && Math.min(band.y + band.h, g.y + g.h) - Math.max(band.y, g.y) > 0) return false;
|
|
208
|
+
return true;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (clear(base)) return base;
|
|
212
|
+
for (let step = 0.02; step <= 0.6; step += 0.02) {
|
|
213
|
+
for (const candidate of [base - step, base + step]) {
|
|
214
|
+
if (clear(candidate)) return candidate;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return base;
|
|
218
|
+
}
|