@ossclip/scenes 0.1.4 → 0.1.6
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 +6 -2
- package/src/EdlVideo.tsx +5 -2
- package/src/SceneLayer.tsx +4 -2
- package/src/components/BulletList.tsx +1 -1
- package/src/fit.ts +22 -3
- package/src/frames.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossclip/scenes",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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.6"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"react": ">=18",
|
package/src/CaptionTrack.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
3
|
import type { CaptionLine, SceneCue } from "@ossclip/core/browser";
|
|
4
4
|
import { safeAreaFor, activeCueAt } from "./stage";
|
|
5
|
+
import { frameWindow } from "./frames";
|
|
5
6
|
import { captionAnchorAvoiding, regionsDuring, type OccupiedRegion } from "./source-fit";
|
|
6
7
|
|
|
7
8
|
export interface CaptionTrackProps {
|
|
@@ -186,8 +187,11 @@ export const CaptionTrack: React.FC<CaptionTrackProps> = ({
|
|
|
186
187
|
frame,
|
|
187
188
|
)
|
|
188
189
|
: verticalAnchor;
|
|
189
|
-
|
|
190
|
-
|
|
190
|
+
// End frame from the end TIME, never from a rounded duration — see
|
|
191
|
+
// frameWindow. Two lines either side of a cue boundary resolve to
|
|
192
|
+
// different anchors, so a one-frame overlap there renders as two
|
|
193
|
+
// captions stacked at two heights (§115).
|
|
194
|
+
const { from, durationInFrames } = frameWindow(line.start, line.end, fps);
|
|
191
195
|
return (
|
|
192
196
|
<Sequence key={i} from={from} durationInFrames={durationInFrames}>
|
|
193
197
|
<LineView
|
package/src/EdlVideo.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useMemo } from "react";
|
|
2
2
|
import { AbsoluteFill, OffthreadVideo, Sequence, useVideoConfig } from "remotion";
|
|
3
3
|
import type { KeptSpan } from "@ossclip/core/browser";
|
|
4
|
+
import { frameWindow } from "./frames";
|
|
4
5
|
|
|
5
6
|
export interface EdlVideoProps {
|
|
6
7
|
src: string;
|
|
@@ -54,8 +55,10 @@ export const EdlVideo: React.FC<EdlVideoProps> = ({
|
|
|
54
55
|
return (
|
|
55
56
|
<AbsoluteFill style={{ backgroundColor: background }}>
|
|
56
57
|
{spans.map((sp, i) => {
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
// §115: from the end TIME. Beyond stacking two spans for a frame, a
|
|
59
|
+
// duration that is one frame long also skews the fade ramp below,
|
|
60
|
+
// which measures against `durationInFrames`.
|
|
61
|
+
const { from, durationInFrames } = frameWindow(sp.outIn, sp.outOut, fps);
|
|
59
62
|
return (
|
|
60
63
|
<Sequence key={i} from={from} durationInFrames={durationInFrames} premountFor={fps}>
|
|
61
64
|
<AbsoluteFill style={{ transform: `scale(${scales[i]})` }}>
|
package/src/SceneLayer.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig } from "remotio
|
|
|
3
3
|
import type { SceneCue, Theme } from "@ossclip/core/browser";
|
|
4
4
|
import { graphicSlotFor } from "./stage";
|
|
5
5
|
import { fitScale } from "./fit";
|
|
6
|
+
import { frameWindow } from "./frames";
|
|
6
7
|
import { TitleCard } from "./components/TitleCard";
|
|
7
8
|
import { StatCard } from "./components/StatCard";
|
|
8
9
|
import { RuleCard } from "./components/RuleCard";
|
|
@@ -106,8 +107,9 @@ export const SceneLayer: React.FC<{ cues: SceneCue[]; theme: Theme }> = ({ cues,
|
|
|
106
107
|
// not veto the component.
|
|
107
108
|
const slot = graphicSlotFor(cue, { width, height });
|
|
108
109
|
const Component = COMPONENTS[cue.component];
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
// §115: from the end TIME — a rounded duration can reach a frame past
|
|
111
|
+
// the cue and put two graphics on screen at once.
|
|
112
|
+
const { from, durationInFrames } = frameWindow(cue.startSec, cue.endSec, fps);
|
|
111
113
|
const slotW = slot.w * width;
|
|
112
114
|
const slotH = slot.h * height;
|
|
113
115
|
// Over-video bands keep breathing room (R21 §100): the scrim fills
|
|
@@ -55,7 +55,7 @@ export const BulletList: React.FC<{
|
|
|
55
55
|
heightPx?: number;
|
|
56
56
|
edits?: ElementEdits;
|
|
57
57
|
}> = ({ props, theme, widthPx, heightPx, edits }) => {
|
|
58
|
-
const fontSize = bulletMetrics(props.items, widthPx, heightPx, Boolean(props.title));
|
|
58
|
+
const fontSize = bulletMetrics(props.items, widthPx, heightPx, Boolean(props.title), props.title);
|
|
59
59
|
const titleEnter = useEnter(0);
|
|
60
60
|
return (
|
|
61
61
|
<div
|
package/src/fit.ts
CHANGED
|
@@ -175,7 +175,7 @@ export function estimateHeightPx(
|
|
|
175
175
|
// Self-fitting like the reveal: rows of nowrap uppercase text. Same
|
|
176
176
|
// model bulletMetrics solves against — the two cannot disagree.
|
|
177
177
|
const items = arr(props.items).map((i) => str(i));
|
|
178
|
-
const font = bulletMetrics(items, widthPx, heightPx, Boolean(str(props.title)));
|
|
178
|
+
const font = bulletMetrics(items, widthPx, heightPx, Boolean(str(props.title)), str(props.title));
|
|
179
179
|
return bulletStackHeightPx(items.length, font, Boolean(str(props.title)));
|
|
180
180
|
}
|
|
181
181
|
}
|
|
@@ -257,7 +257,21 @@ const BULLET_MIN_FONT = 36;
|
|
|
257
257
|
const BULLET_MAX_FONT = 120;
|
|
258
258
|
/** Row: 1.15 line-height + 0.45em gap; the kicker title ≈ one smaller row. */
|
|
259
259
|
const BULLET_ROW_H = 1.6;
|
|
260
|
-
|
|
260
|
+
/**
|
|
261
|
+
* The kicker title's real height (R27 §121). It renders at `fontSize * 0.36`
|
|
262
|
+
* (`BulletList.tsx:75`) plus the column's `0.45em` gap — 0.36x1.2 + 0.45 ≈
|
|
263
|
+
* 0.88em, not the 1.1em this used to charge. Over-charging the title shrinks
|
|
264
|
+
* `heightFit`, so a titled list was solved against a budget ~0.2em smaller
|
|
265
|
+
* than the one it actually occupies.
|
|
266
|
+
*/
|
|
267
|
+
const BULLET_TITLE_H = 0.88;
|
|
268
|
+
/**
|
|
269
|
+
* Per-character advance of the kicker, in ems of the BASE font: its own
|
|
270
|
+
* 0.36em type times (glyph + the 0.28em tracking it is set with). Letter
|
|
271
|
+
* spacing was not modelled at all, so a long title could overrun the slot
|
|
272
|
+
* width unbudgeted.
|
|
273
|
+
*/
|
|
274
|
+
const BULLET_TITLE_CHAR_W = (CHAR_W_UPPER + 0.28) * 0.36;
|
|
261
275
|
/** Glyph column: the ▸ plus its gap, in ems. */
|
|
262
276
|
const BULLET_GLYPH_W = 1.1;
|
|
263
277
|
|
|
@@ -276,10 +290,15 @@ export function bulletMetrics(
|
|
|
276
290
|
widthPx = 831,
|
|
277
291
|
heightPx = Infinity,
|
|
278
292
|
hasTitle = false,
|
|
293
|
+
title = "",
|
|
279
294
|
): number {
|
|
280
295
|
const longest = items.reduce((max, t) => Math.max(max, t.length), 0);
|
|
281
296
|
if (longest === 0) return BULLET_MIN_FONT;
|
|
282
|
-
|
|
297
|
+
// Whichever of the longest bullet and the tracked-out kicker needs more room.
|
|
298
|
+
const widthFit = Math.min(
|
|
299
|
+
widthPx / (longest * CHAR_W_UPPER + BULLET_GLYPH_W),
|
|
300
|
+
title ? widthPx / (title.length * BULLET_TITLE_CHAR_W) : Infinity,
|
|
301
|
+
);
|
|
283
302
|
const heightFit =
|
|
284
303
|
(heightPx * FILL_TARGET) /
|
|
285
304
|
(Math.max(1, items.length) * BULLET_ROW_H + (hasTitle ? BULLET_TITLE_H : 0));
|
package/src/frames.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output seconds → a `<Sequence>` frame window.
|
|
3
|
+
*
|
|
4
|
+
* The invariant: **the end frame comes from the end TIME**, never from a
|
|
5
|
+
* rounded duration. `Math.round(start · fps) + Math.round((end - start) · fps)`
|
|
6
|
+
* is not `Math.round(end · fps)` — the two roundings are independent, so a
|
|
7
|
+
* window whose start rounds down and whose duration rounds up reaches one
|
|
8
|
+
* frame past where it should, and lands on top of the next window's first
|
|
9
|
+
* frame (FINDINGS §115).
|
|
10
|
+
*
|
|
11
|
+
* That off-by-one is invisible in the timings — the seconds never overlap —
|
|
12
|
+
* and shows up only in the render, as one frame with two things on it. Every
|
|
13
|
+
* adjacent-`<Sequence>` track wants this: captions (two lines at once, and at
|
|
14
|
+
* two different anchors when the pair straddles a cue boundary), scene cues
|
|
15
|
+
* (two graphics at once), and the EDL (two video spans, plus a fade ramp
|
|
16
|
+
* measured against the wrong length).
|
|
17
|
+
*
|
|
18
|
+
* Sub-frame windows still get one frame — a zero-length `<Sequence>` renders
|
|
19
|
+
* nothing, and a caption that vanishes entirely is worse than one that
|
|
20
|
+
* briefly shares a frame.
|
|
21
|
+
*/
|
|
22
|
+
export function frameWindow(
|
|
23
|
+
startSec: number,
|
|
24
|
+
endSec: number,
|
|
25
|
+
fps: number,
|
|
26
|
+
): { from: number; durationInFrames: number } {
|
|
27
|
+
const from = Math.round(startSec * fps);
|
|
28
|
+
const to = Math.round(endSec * fps);
|
|
29
|
+
return { from, durationInFrames: Math.max(1, to - from) };
|
|
30
|
+
}
|