@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
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
|
+
};
|
package/src/EdlVideo.tsx
ADDED
|
@@ -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
|
+
};
|