@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/scenes",
3
- "version": "0.1.4",
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.4"
22
+ "@ossclip/core": "0.1.6"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "react": ">=18",
@@ -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
- const from = Math.round(line.start * fps);
190
- const durationInFrames = Math.max(1, Math.round((line.end - line.start) * fps));
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
- const from = Math.round(sp.outIn * fps);
58
- const durationInFrames = Math.max(1, Math.round((sp.outOut - sp.outIn) * fps));
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]})` }}>
@@ -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
- const from = Math.round(cue.startSec * fps);
110
- const durationInFrames = Math.max(1, Math.round((cue.endSec - cue.startSec) * fps));
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
- const BULLET_TITLE_H = 1.1;
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
- const widthFit = widthPx / (longest * CHAR_W_UPPER + BULLET_GLYPH_W);
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
+ }