@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 ADDED
@@ -0,0 +1,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Muhammad Ahsan Ayaz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ Note (not part of the MIT grant above, which covers this repository's own
26
+ code): rendering depends on Remotion, which is source-available under its own
27
+ two-tier licence — see https://github.com/remotion-dev/remotion/blob/main/LICENSE.md.
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # @ossclip/scenes
2
+
3
+ Part of [**ossclip**](https://github.com/AhsanAyaz/ossclip) — a local-first CLI that turns a talking-head take into a finished short: silence and filler words cut, word-timed kinetic captions, face-aware framing, and LLM-planned code-rendered on-screen graphics.
4
+
5
+ This package is the scene library and stage geometry — React components shared by the editor preview and the render.
6
+
7
+ It is published so the CLI can depend on it and so the pieces are reusable, but the supported entry point is the CLI:
8
+
9
+ ```sh
10
+ npm install -g ossclip
11
+ ossclip doctor
12
+ ```
13
+
14
+ APIs here move between rounds — pin an exact version if you depend on them directly.
15
+
16
+ **Documentation:** [github.com/AhsanAyaz/ossclip](https://github.com/AhsanAyaz/ossclip)
17
+
18
+ ## Licence
19
+
20
+ MIT. Rendering depends on [Remotion](https://www.remotion.dev/), which carries [its own two-tier licence](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md).
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@ossclip/scenes",
3
+ "version": "0.1.0",
4
+ "description": "ossclip's scene library and stage geometry — React components shared by preview and render",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/AhsanAyaz/ossclip.git",
10
+ "directory": "packages/scenes"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.ts",
14
+ "./geometry": "./src/geometry.ts"
15
+ },
16
+ "files": [
17
+ "README.md",
18
+ "src"
19
+ ],
20
+ "dependencies": {
21
+ "zod": "^3.25.0",
22
+ "@ossclip/core": "0.1.0"
23
+ },
24
+ "peerDependencies": {
25
+ "react": ">=18",
26
+ "remotion": "^4.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "^18.3.0",
30
+ "react": "^18.3.1",
31
+ "remotion": "^4.0.0"
32
+ },
33
+ "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/AhsanAyaz/ossclip/issues"
36
+ },
37
+ "author": "Muhammad Ahsan Ayaz"
38
+ }
@@ -0,0 +1,209 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import type { CaptionLine, SceneCue } from "@ossclip/core/browser";
4
+ import { safeAreaFor, activeCueAt } from "./stage";
5
+ import { captionAnchorAvoiding, regionsDuring, type OccupiedRegion } from "./source-fit";
6
+
7
+ export interface CaptionTrackProps {
8
+ lines: CaptionLine[];
9
+ /** Scene cues, for layout-aware anchoring/visibility. Empty = always visible. */
10
+ cues?: SceneCue[];
11
+ /** Vertical center of the caption block, as a fraction of frame height. */
12
+ verticalAnchor?: number;
13
+ fontSizePx?: number;
14
+ activeColor?: string;
15
+ /**
16
+ * The comment-CTA word: at the moment it is ASKED FOR, the caption word
17
+ * renders quoted and capitalized — reinforcing the ask for muted viewers
18
+ * (FINDINGS §16).
19
+ */
20
+ ctaKeyword?: string;
21
+ /**
22
+ * When the ask is on screen, in output seconds — the CTA cue's own window.
23
+ * Required for the treatment to apply at all: quoting marks *the word you
24
+ * type in the comments*, so styling every ordinary use of it (nine times in
25
+ * one take) inverts the meaning and devalues the real ask (FINDINGS §22).
26
+ */
27
+ ctaWindow?: { startSec: number; endSec: number };
28
+ /**
29
+ * Bands where the SOURCE already has burned-in text. Captions relocate to
30
+ * clear them but are NEVER hidden — they are the accessibility layer, so a
31
+ * crowded caption still beats a missing one (FINDINGS §26).
32
+ */
33
+ sourceTextRegions?: OccupiedRegion[];
34
+ }
35
+
36
+ /**
37
+ * A caption word may sit slightly outside the cue that carries the ask — the
38
+ * cue starts at its anchor's first word, and speech runs on either side. The
39
+ * window is narrow enough that a near miss is glaring and the next occurrence
40
+ * of the word is seconds away, so a small pad is free insurance.
41
+ */
42
+ const CTA_WINDOW_PAD_SEC = 0.4;
43
+
44
+ /** `agents.` → `"AGENTS".` — quote-and-caps the word, punctuation kept outside. */
45
+ export function ctaDisplay(text: string, keyword: string | undefined): string {
46
+ if (!keyword) return text;
47
+ const core = text.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, "");
48
+ if (core.toLowerCase() !== keyword.toLowerCase()) return text;
49
+ return text.replace(core, `"${core.toUpperCase()}"`);
50
+ }
51
+
52
+ /** Is this caption word inside the CTA moment? No window ⇒ no treatment. */
53
+ export function inCtaWindow(
54
+ startSec: number,
55
+ window: { startSec: number; endSec: number } | undefined,
56
+ ): boolean {
57
+ if (!window) return false;
58
+ return (
59
+ startSec >= window.startSec - CTA_WINDOW_PAD_SEC &&
60
+ startSec <= window.endSec + CTA_WINDOW_PAD_SEC
61
+ );
62
+ }
63
+
64
+ const LineView: React.FC<{
65
+ line: CaptionLine;
66
+ /** This line's first word's index in the WHOLE caption stream — the id the
67
+ * editor's retype override keys on (`OverrideDoc.captions`). */
68
+ wordOffset: number;
69
+ verticalAnchor: number;
70
+ fontSizePx: number;
71
+ activeColor: string;
72
+ ctaKeyword?: string;
73
+ ctaWindow?: { startSec: number; endSec: number };
74
+ }> = ({ line, wordOffset, verticalAnchor, fontSizePx, activeColor, ctaKeyword, ctaWindow }) => {
75
+ const frame = useCurrentFrame();
76
+ const { fps, width, height } = useVideoConfig();
77
+ const safeArea = safeAreaFor({ width, height });
78
+ // The parent <Sequence> starts at line.start, so local frame 0 === line.start.
79
+ const t = line.start + frame / fps;
80
+ return (
81
+ <AbsoluteFill style={{ pointerEvents: "none" }}>
82
+ <div
83
+ style={{
84
+ position: "absolute",
85
+ top: `${verticalAnchor * 100}%`,
86
+ left: 0,
87
+ right: 0,
88
+ transform: "translateY(-50%)",
89
+ display: "flex",
90
+ justifyContent: "center",
91
+ gap: "0.28em",
92
+ flexWrap: "wrap",
93
+ // Keep caption text clear of the platform's right-hand action rail.
94
+ paddingLeft: `${safeArea.left * 100}%`,
95
+ paddingRight: `${safeArea.right * 100}%`,
96
+ fontFamily:
97
+ "'Inter', 'Helvetica Neue', 'Arial Black', Arial, sans-serif",
98
+ fontWeight: 900,
99
+ fontSize: fontSizePx,
100
+ lineHeight: 1.15,
101
+ textAlign: "center",
102
+ color: "white",
103
+ WebkitTextStroke: "10px rgba(0,0,0,0.85)",
104
+ paintOrder: "stroke fill",
105
+ textShadow: "0 4px 24px rgba(0,0,0,0.55)",
106
+ }}
107
+ >
108
+ {line.words.map((w, i) => {
109
+ const active = t >= w.start && t <= Math.max(w.end, w.start + 0.12);
110
+ return (
111
+ <span
112
+ key={i}
113
+ // The editor double-click retypes a word in place; the RAW text
114
+ // rides along because the rendered text may be CTA-decorated,
115
+ // and the retype's stale-guard must compare against the truth.
116
+ data-caption-word={wordOffset + i}
117
+ data-caption-text={w.text}
118
+ style={{
119
+ display: "inline-block",
120
+ // Words are individually hit-testable for the editor (the
121
+ // parent layer stays pointer-events: none); harmless in the
122
+ // render, where nothing dispatches events.
123
+ pointerEvents: "auto",
124
+ transform: active ? "scale(1.08)" : "scale(1)",
125
+ color: active ? activeColor : "white",
126
+ transition: "transform 60ms linear",
127
+ }}
128
+ >
129
+ {/* Per WORD, not per line: a line straddling the cue boundary
130
+ styles only the word actually inside the ask. */}
131
+ {ctaDisplay(w.text, inCtaWindow(w.start, ctaWindow) ? ctaKeyword : undefined)}
132
+ </span>
133
+ );
134
+ })}
135
+ </div>
136
+ </AbsoluteFill>
137
+ );
138
+ };
139
+
140
+ /**
141
+ * Word-timed kinetic captions. All timings are OUTPUT time. When scene cues
142
+ * are provided, each line is anchored per the active layout's caption slot
143
+ * and hidden entirely while a graphic owns the frame (PHASE1 §1).
144
+ */
145
+ export const CaptionTrack: React.FC<CaptionTrackProps> = ({
146
+ lines,
147
+ cues = [],
148
+ verticalAnchor = 0.76,
149
+ fontSizePx = 64,
150
+ activeColor = "#FFE14D",
151
+ ctaKeyword,
152
+ ctaWindow,
153
+ sourceTextRegions = [],
154
+ }) => {
155
+ const { fps, width, height } = useVideoConfig();
156
+ const frame = { width, height };
157
+ // Each line's first-word index in the whole stream, so a word's id is
158
+ // stable regardless of which line the layout put it on.
159
+ const offsets: number[] = [];
160
+ let running = 0;
161
+ for (const line of lines) {
162
+ offsets.push(running);
163
+ running += line.words.length;
164
+ }
165
+ return (
166
+ <AbsoluteFill>
167
+ {lines.map((line, i) => {
168
+ const active = cues.length > 0 ? activeCueAt(cues, line.start) : null;
169
+ // ONE rect-aware path for every line (R11 Task 2b). The old split —
170
+ // `captionAnchorAvoiding` only when the source had burned-in text,
171
+ // the pure layout anchor otherwise — meant a hand-moved graphic box
172
+ // silently sat on top of the captions on a clean source, the common
173
+ // case. With no regions and no moved rect this resolves to exactly
174
+ // the layout anchor the old branch returned.
175
+ // A hand-set per-scene anchor (R15 §56) wins over the automatic
176
+ // chain outright — like every other hand edit, the user's placement
177
+ // is a decision, not an input to the avoidance search.
178
+ const anchor =
179
+ active?.captionY !== undefined
180
+ ? active.captionY
181
+ : cues.length > 0 || sourceTextRegions.length > 0
182
+ ? captionAnchorAvoiding(
183
+ active?.layout ?? "full-bleed",
184
+ regionsDuring(sourceTextRegions, line.start, line.end),
185
+ active?.graphicRect,
186
+ frame,
187
+ )
188
+ : verticalAnchor;
189
+ const from = Math.round(line.start * fps);
190
+ const durationInFrames = Math.max(1, Math.round((line.end - line.start) * fps));
191
+ return (
192
+ <Sequence key={i} from={from} durationInFrames={durationInFrames}>
193
+ <LineView
194
+ line={line}
195
+ wordOffset={offsets[i]!}
196
+ verticalAnchor={anchor}
197
+ // Per-scene size multiplier (R16 §64) — resolved per line like
198
+ // the anchor, from the cue the line starts under.
199
+ fontSizePx={fontSizePx * (active?.captionScale ?? 1)}
200
+ activeColor={activeColor}
201
+ ctaKeyword={ctaKeyword}
202
+ ctaWindow={ctaWindow}
203
+ />
204
+ </Sequence>
205
+ );
206
+ })}
207
+ </AbsoluteFill>
208
+ );
209
+ };
@@ -0,0 +1,87 @@
1
+ import React, { useMemo } from "react";
2
+ import { AbsoluteFill, OffthreadVideo, Sequence, useVideoConfig } from "remotion";
3
+ import type { KeptSpan } from "@ossclip/core/browser";
4
+
5
+ export interface EdlVideoProps {
6
+ src: string;
7
+ /** Kept spans from the TimeMap (plain JSON — precomputed in the pipeline). */
8
+ spans: KeptSpan[];
9
+ /** Scale applied on alternating segments to conceal jump cuts. */
10
+ punchInScale?: number;
11
+ /** Removed gaps shorter than this don't toggle the punch-in (cut is invisible anyway). */
12
+ punchThresholdSec?: number;
13
+ /** Audio micro-fade at each cut boundary, in seconds. */
14
+ audioFadeSec?: number;
15
+ /**
16
+ * What shows where the video doesn't reach. Black by default — with a
17
+ * cover-cropped source it never shows at all. Under `--source-fit contain`
18
+ * the picture is INSET in its slot, so this backing would paint black bars
19
+ * over the stage's own backdrop; the composition passes `transparent` there
20
+ * and lets the backdrop through.
21
+ */
22
+ background?: string;
23
+ }
24
+
25
+ /**
26
+ * Plays the kept spans of the source back-to-back — the EDL made visible.
27
+ * Jump cuts are concealed by alternating a slight punch-in whenever the
28
+ * removed gap is long enough to produce a visible jump.
29
+ */
30
+ export const EdlVideo: React.FC<EdlVideoProps> = ({
31
+ src,
32
+ spans,
33
+ punchInScale = 1.07,
34
+ punchThresholdSec = 0.15,
35
+ audioFadeSec = 0.01,
36
+ background = "black",
37
+ }) => {
38
+ const { fps } = useVideoConfig();
39
+
40
+ const scales = useMemo(() => {
41
+ const out: number[] = [];
42
+ let punched = false;
43
+ for (let i = 0; i < spans.length; i++) {
44
+ const prev = spans[i - 1];
45
+ const gap = prev ? spans[i]!.srcIn - prev.srcOut : 0;
46
+ if (i > 0 && gap >= punchThresholdSec) punched = !punched;
47
+ out.push(punched ? punchInScale : 1);
48
+ }
49
+ return out;
50
+ }, [spans, punchInScale, punchThresholdSec]);
51
+
52
+ const fadeFrames = Math.max(1, Math.round(audioFadeSec * fps));
53
+
54
+ return (
55
+ <AbsoluteFill style={{ backgroundColor: background }}>
56
+ {spans.map((sp, i) => {
57
+ const from = Math.round(sp.outIn * fps);
58
+ const durationInFrames = Math.max(1, Math.round((sp.outOut - sp.outIn) * fps));
59
+ return (
60
+ <Sequence key={i} from={from} durationInFrames={durationInFrames} premountFor={fps}>
61
+ <AbsoluteFill style={{ transform: `scale(${scales[i]})` }}>
62
+ <OffthreadVideo
63
+ src={src}
64
+ trimBefore={Math.round(sp.srcIn * fps)}
65
+ trimAfter={Math.round(sp.srcOut * fps)}
66
+ style={{
67
+ width: "100%",
68
+ height: "100%",
69
+ objectFit: "cover",
70
+ // Crop bias set by VideoStage per layout (§11) — vertical
71
+ // for a portrait source, horizontal for a landscape one.
72
+ objectPosition: "var(--ossclip-obj-x, 50%) var(--ossclip-obj-y, 50%)",
73
+ }}
74
+ volume={(f) =>
75
+ Math.max(
76
+ 0,
77
+ Math.min(1, (f + 1) / fadeFrames, (durationInFrames - f) / fadeFrames),
78
+ )
79
+ }
80
+ />
81
+ </AbsoluteFill>
82
+ </Sequence>
83
+ );
84
+ })}
85
+ </AbsoluteFill>
86
+ );
87
+ };
@@ -0,0 +1,184 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import type { SceneCue, Theme } from "@ossclip/core/browser";
4
+ import { graphicSlotFor } from "./stage";
5
+ import { fitScale } from "./fit";
6
+ import { TitleCard } from "./components/TitleCard";
7
+ import { StatCard } from "./components/StatCard";
8
+ import { RuleCard } from "./components/RuleCard";
9
+ import { StrikethroughReveal } from "./components/StrikethroughReveal";
10
+ import { FlowDiagram } from "./components/FlowDiagram";
11
+ import { TerminalMock } from "./components/TerminalMock";
12
+ import { ChatMock } from "./components/ChatMock";
13
+ import { ScreenshotFrame } from "./components/ScreenshotFrame";
14
+ import { BulletList } from "./components/BulletList";
15
+ import { compensateEdits, type ElementEdits } from "./editable";
16
+
17
+ /* eslint-disable @typescript-eslint/no-explicit-any -- props are registry-validated upstream */
18
+ const COMPONENTS: Record<
19
+ NonNullable<SceneCue["component"]>,
20
+ React.FC<{ props: any; theme: Theme; widthPx?: number; heightPx?: number; edits?: ElementEdits }>
21
+ > = {
22
+ TitleCard,
23
+ StatCard,
24
+ RuleCard,
25
+ StrikethroughReveal,
26
+ FlowDiagram,
27
+ TerminalMock,
28
+ ChatMock,
29
+ ScreenshotFrame,
30
+ BulletList,
31
+ };
32
+
33
+ /** Seconds a graphic spends leaving. Matches LAYOUT_TRANSITION_SEC's order of
34
+ * magnitude so the graphic departs WITH the video slot's morph — the reported
35
+ * failure was the split view closing first and the card then blinking out. */
36
+ const EXIT_SEC = 0.3;
37
+
38
+ /**
39
+ * Layouts whose graphic slot sits over LIVE video (R20 §94). Everywhere else
40
+ * the graphic lands on the stage background and the theme guarantees its
41
+ * contrast; here the background is whatever pixels the footage happens to
42
+ * show, and a typographic component (TitleCard, StrikethroughReveal) can land
43
+ * white text on a bright wall. The slot gets a frosted scrim: theme-bg tint
44
+ * over a backdrop blur. Slot-shaped rather than a broadcast bottom-gradient
45
+ * so it FOLLOWS the box when the editor drags or resizes it.
46
+ */
47
+ const OVER_VIDEO_LAYOUTS = new Set<SceneCue["layout"]>(["lower-third", "full-bleed"]);
48
+ const SCRIM_ALPHA = 0.55;
49
+
50
+ const scrimColor = (themeBg: string): string => {
51
+ const m = /^#([0-9a-fA-F]{6})$/.exec(themeBg.trim());
52
+ const [r, g, b] = m
53
+ ? [0, 2, 4].map((i) => Number.parseInt(m[1]!.slice(i, i + 2), 16))
54
+ : [0, 0, 0];
55
+ return `rgba(${r}, ${g}, ${b}, ${SCRIM_ALPHA})`;
56
+ };
57
+
58
+ /**
59
+ * Uniform exit for every graphic (R16 §69). Components own their ENTRANCES
60
+ * (staggered rises, per element); the exit lives here at the layer because it
61
+ * is the cue's END doing the animating, and every component leaving the same
62
+ * way is what makes the cut read as designed. Inside the cue's Sequence, so
63
+ * frame 0 is the cue's own start.
64
+ */
65
+ const ExitFade: React.FC<{ durationInFrames: number; children: React.ReactNode }> = ({
66
+ durationInFrames,
67
+ children,
68
+ }) => {
69
+ const frame = useCurrentFrame();
70
+ const { fps } = useVideoConfig();
71
+ const remaining = (durationInFrames - frame) / fps;
72
+ const p = Math.min(1, Math.max(0, remaining / EXIT_SEC));
73
+ const ease = p * (2 - p);
74
+ return (
75
+ <div
76
+ style={{
77
+ width: "100%",
78
+ height: "100%",
79
+ display: "flex",
80
+ alignItems: "center",
81
+ justifyContent: "center",
82
+ opacity: ease,
83
+ transform: ease < 1 ? `translateY(${(1 - ease) * 18}px)` : undefined,
84
+ }}
85
+ >
86
+ {children}
87
+ </div>
88
+ );
89
+ };
90
+
91
+ /** Renders each cue's graphic into its layout's graphic slot, scene-local time. */
92
+ export const SceneLayer: React.FC<{ cues: SceneCue[]; theme: Theme }> = ({ cues, theme }) => {
93
+ const { fps, width, height } = useVideoConfig();
94
+ return (
95
+ <AbsoluteFill style={{ pointerEvents: "none" }}>
96
+ {cues.map((cue) => {
97
+ // Plain cues (derived timeline filler) carry no graphic and no
98
+ // `data-edit-scene` — the talking head IS the scene. The component/
99
+ // props guards double as the type narrowing their optionality forces.
100
+ if (cue.kind === "plain" || !cue.component || !cue.props) return null;
101
+ // A cue may carry its own rect — routed there by source-text
102
+ // avoidance (FINDINGS §26) or set by hand in the editor (R11 Task
103
+ // 2). Never null (R13): a layout with no slot of its own
104
+ // (full-bleed) falls back to `FULL_BLEED_GRAPHIC_SLOT` rather than
105
+ // silently dropping the graphic — layout places the video, it does
106
+ // not veto the component.
107
+ const slot = graphicSlotFor(cue, { width, height });
108
+ const Component = COMPONENTS[cue.component];
109
+ const from = Math.round(cue.startSec * fps);
110
+ const durationInFrames = Math.max(1, Math.round((cue.endSec - cue.startSec) * fps));
111
+ const slotW = slot.w * width;
112
+ const slotH = slot.h * height;
113
+ // Over-video bands keep breathing room (R21 §100): the scrim fills
114
+ // the slot, the CONTENT solves against an inset box, so type never
115
+ // touches the band's edge. Elsewhere padding stays 0 — those slots
116
+ // sit on the stage background and already carry their own air.
117
+ const overVideo = OVER_VIDEO_LAYOUTS.has(cue.layout);
118
+ const pad = overVideo ? Math.min(24, Math.round(slotH * 0.12)) : 0;
119
+ const contentW = slotW - pad * 2;
120
+ const contentH = slotH - pad * 2;
121
+ // Fill the slot instead of floating at natural size in it (§23). The
122
+ // component lays out at contentW/scale and is then scaled up, so its
123
+ // rendered width is exactly the content width while its type grows.
124
+ const scale = fitScale(cue.component, cue.props, { widthPx: contentW, heightPx: contentH });
125
+ return (
126
+ <Sequence key={cue.id} from={from} durationInFrames={durationInFrames}>
127
+ <div
128
+ data-edit-scene={cue.id}
129
+ style={{
130
+ position: "absolute",
131
+ left: `${slot.x * 100}%`,
132
+ top: `${slot.y * 100}%`,
133
+ width: `${slot.w * 100}%`,
134
+ height: `${slot.h * 100}%`,
135
+ display: "flex",
136
+ alignItems: "center",
137
+ justifyContent: "center",
138
+ // The parent AbsoluteFill is `pointer-events: none` so
139
+ // scrubbing/other player controls pass through the graphic
140
+ // layer everywhere else; re-enable it just for this cue's
141
+ // box so the editor's `elementFromPoint` hit-test can reach
142
+ // its `data-edit-id` leaves at all.
143
+ pointerEvents: "auto",
144
+ // Content that cannot fit even at MIN_SCALE is clipped here
145
+ // rather than bleeding outside the platform safe area, which
146
+ // is what it did silently before (§6a).
147
+ overflow: "hidden",
148
+ }}
149
+ >
150
+ <ExitFade durationInFrames={durationInFrames}>
151
+ {overVideo ? (
152
+ <div
153
+ style={{
154
+ position: "absolute",
155
+ inset: 0,
156
+ background: scrimColor(theme.bg),
157
+ backdropFilter: "blur(14px)",
158
+ WebkitBackdropFilter: "blur(14px)",
159
+ borderRadius: theme.radiusPx,
160
+ }}
161
+ />
162
+ ) : null}
163
+ {/* position:relative so the content paints (and hit-tests)
164
+ above the positioned scrim — document order alone loses. */}
165
+ <div style={{ width: contentW / scale, transform: `scale(${scale})`, position: "relative" }}>
166
+ <Component
167
+ props={cue.props}
168
+ theme={theme}
169
+ widthPx={contentW / scale}
170
+ heightPx={contentH / scale}
171
+ // Stored nudges are composition px; this wrapper scales by
172
+ // `scale`, so they are counter-divided here or a drag lands
173
+ // `scale`× past where it was dropped (PLAN Task 1).
174
+ edits={compensateEdits(cue.elements, scale)}
175
+ />
176
+ </div>
177
+ </ExitFade>
178
+ </div>
179
+ </Sequence>
180
+ );
181
+ })}
182
+ </AbsoluteFill>
183
+ );
184
+ };