@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.
@@ -0,0 +1,179 @@
1
+ import { contentRectAt, type ContentRect, type ContentRectSegment } from "@ossclip/core/browser";
2
+
3
+ /**
4
+ * Rendering a source whose framing changes mid-take (PLAN Task C).
5
+ *
6
+ * A uniformly letterboxed source is cropped once, by ffmpeg, into the
7
+ * mezzanine — the bars stop existing before any React sees them (Task 7). That
8
+ * is impossible when the framing changes: there is no single crop, and the
9
+ * letterboxed stretches of the author's clip hold a LANDSCAPE picture, which no
10
+ * amount of ffmpeg cropping turns into a portrait frame. Those stretches have
11
+ * to be cover-cropped, with the face bias, exactly as a landscape source
12
+ * already is — which is work the stage does per frame anyway.
13
+ *
14
+ * So the crop moves to render time. This module answers one question: how must
15
+ * the FULL source frame be sized and offset so that a sub-rect of it covers the
16
+ * slot? The video element then fills the returned box, whose aspect ratio is
17
+ * the source's own, so `cover` and `fill` agree and nothing is distorted.
18
+ *
19
+ * With a full-frame rect this reduces exactly to plain `object-fit: cover` —
20
+ * one code path, so the common case cannot drift from the mixed one.
21
+ */
22
+
23
+ export interface CoverBox {
24
+ /** CSS px for the FULL source frame, of which only the rect is visible. */
25
+ width: number;
26
+ height: number;
27
+ left: number;
28
+ top: number;
29
+ }
30
+
31
+ /**
32
+ * Size and offset the full source frame so `rect` covers `slot`.
33
+ *
34
+ * `posX`/`posY` are the same 0..1 crop bias `object-position` takes — 0.5 is
35
+ * centred, and the stage derives them from the measured face — applied to the
36
+ * OVERFLOW, which is what makes this behave like `object-position` rather than
37
+ * an unrelated second positioning system.
38
+ */
39
+ export function contentCoverBox(
40
+ source: { width: number; height: number },
41
+ rect: { x: number; y: number; w: number; h: number },
42
+ slot: { width: number; height: number },
43
+ posX = 0.5,
44
+ posY = 0.5,
45
+ ): CoverBox {
46
+ const sw = Math.max(1, source.width);
47
+ const sh = Math.max(1, source.height);
48
+ const cw = Math.max(1, rect.w);
49
+ const ch = Math.max(1, rect.h);
50
+
51
+ // Cover the slot with the CONTENT rect, not the frame.
52
+ const k = Math.max(slot.width / cw, slot.height / ch);
53
+
54
+ // Park the rect's top-left at the slot's origin, then spend the overflow
55
+ // according to the bias.
56
+ const left = -rect.x * k + (slot.width - cw * k) * clamp01(posX);
57
+ const top = -rect.y * k + (slot.height - ch * k) * clamp01(posY);
58
+
59
+ return { width: sw * k, height: sh * k, left, top };
60
+ }
61
+
62
+ function clamp01(v: number): number {
63
+ return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0.5;
64
+ }
65
+
66
+ /**
67
+ * Size and offset the full source frame so `rect` FITS inside `slot`,
68
+ * centred — option (b), the fallback when normalization refuses because the
69
+ * strip is too small to cover the output without visible softening. The strip
70
+ * renders at its natural aspect against the stage backdrop: an honest inset
71
+ * rather than a fake full-frame shot. The face bias is irrelevant here —
72
+ * nothing is cropped away, so there is nothing to bias toward.
73
+ */
74
+ export function contentFitBox(
75
+ source: { width: number; height: number },
76
+ rect: { x: number; y: number; w: number; h: number },
77
+ slot: { width: number; height: number },
78
+ ): CoverBox {
79
+ const sw = Math.max(1, source.width);
80
+ const sh = Math.max(1, source.height);
81
+ const cw = Math.max(1, rect.w);
82
+ const ch = Math.max(1, rect.h);
83
+ const k = Math.min(slot.width / cw, slot.height / ch);
84
+ const left = -rect.x * k + (slot.width - cw * k) / 2;
85
+ const top = -rect.y * k + (slot.height - ch * k) / 2;
86
+ return { width: sw * k, height: sh * k, left, top };
87
+ }
88
+
89
+ export type ContentCropMode = "cover" | "fit";
90
+
91
+ /**
92
+ * How the SOURCE meets the video slot — a different question from
93
+ * `ContentCropMode`, which is about letterbox strips inside a source whose
94
+ * framing changes mid-take. This one is about the source's own shape:
95
+ *
96
+ * cover fill the slot, cropping whatever doesn't fit (the default, and
97
+ * the right answer for a portrait selfie take)
98
+ * contain show the WHOLE frame, inset against the stage backdrop
99
+ *
100
+ * `contain` exists for LANDSCAPE sources. A 1920×1080 take cover-cropped into
101
+ * a 1080×1920 frame displays at 3413px wide and keeps 1080 of them — 31.6% of
102
+ * the picture, with the speaker's head filling the frame top to bottom. When
103
+ * what's on screen matters (a desk, a monitor, two people, a demo), that crop
104
+ * throws the shot away; `contain` keeps it exactly as recorded.
105
+ */
106
+ export type SourceFit = "cover" | "contain";
107
+
108
+ /**
109
+ * The box that shows the WHOLE source frame inside a slot, centred.
110
+ *
111
+ * `contentFitBox` with a full-frame rect — spelled out as its own function
112
+ * because the two callers mean different things (that one insets a measured
113
+ * content strip; this one insets the source itself) and a future change to one
114
+ * must not silently retune the other.
115
+ */
116
+ export function sourceFitBox(
117
+ source: { width: number; height: number },
118
+ slot: { width: number; height: number },
119
+ ): CoverBox {
120
+ return contentFitBox(source, { x: 0, y: 0, w: source.width, h: source.height }, slot);
121
+ }
122
+
123
+ /** A kept span, as the TimeMap emits it — output and source in one record. */
124
+ export interface SpanLike {
125
+ outIn: number;
126
+ outOut: number;
127
+ srcIn: number;
128
+ srcOut: number;
129
+ }
130
+
131
+ /**
132
+ * SOURCE time for an OUTPUT time, through the kept spans.
133
+ *
134
+ * The content timeline is measured on the source, but everything at render time
135
+ * runs in output time, and with cuts the two are not the same clock. Times
136
+ * between spans (inside a removed gap, which the output never shows) resolve to
137
+ * the nearest span edge rather than to nothing.
138
+ */
139
+ export function sourceTimeAt(spans: readonly SpanLike[], outSec: number): number {
140
+ if (spans.length === 0) return outSec;
141
+ for (const sp of spans) {
142
+ if (outSec >= sp.outIn && outSec < sp.outOut) return sp.srcIn + (outSec - sp.outIn);
143
+ }
144
+ const first = spans[0]!;
145
+ if (outSec < first.outIn) return first.srcIn;
146
+ const last = spans[spans.length - 1]!;
147
+ return last.srcOut;
148
+ }
149
+
150
+ /**
151
+ * The box for a rect in a slot under either mode. `cover` fills the slot from
152
+ * the rect (face-biased); `fit` insets the rect whole. One dispatch point so
153
+ * the two modes cannot drift in how they treat the source frame.
154
+ */
155
+ export function contentBox(
156
+ mode: ContentCropMode,
157
+ source: { width: number; height: number },
158
+ rect: { x: number; y: number; w: number; h: number },
159
+ slot: { width: number; height: number },
160
+ posX: number,
161
+ posY: number,
162
+ ): CoverBox {
163
+ return mode === "fit"
164
+ ? contentFitBox(source, rect, slot)
165
+ : contentCoverBox(source, rect, slot, posX, posY);
166
+ }
167
+
168
+ /** The content rect to render at an OUTPUT time. */
169
+ export function contentRectAtOutput(
170
+ timeline: readonly ContentRectSegment[],
171
+ spans: readonly SpanLike[],
172
+ outSec: number,
173
+ source: { width: number; height: number },
174
+ ): ContentRect {
175
+ if (timeline.length === 0) {
176
+ return { x: 0, y: 0, w: source.width, h: source.height, full: true };
177
+ }
178
+ return contentRectAt(timeline, sourceTimeAt(spans, outSec), source);
179
+ }
@@ -0,0 +1,51 @@
1
+ import type React from "react";
2
+
3
+ /** Per-element nudges from the user's edit layer, keyed by `data-edit-id`. */
4
+ export type ElementEdits =
5
+ | Record<string, { dx?: number; dy?: number; scale?: number }>
6
+ | undefined;
7
+
8
+ /**
9
+ * The style half of an editable leaf; the other half is the `data-edit-id`
10
+ * attribute the editor hit-tests against.
11
+ *
12
+ * Spread LAST in a component's style object so a user nudge wins over the
13
+ * component's own transform. Returns an empty object when untouched, so an
14
+ * unedited element keeps whatever transform its entrance animation set.
15
+ */
16
+ /**
17
+ * Counter-scale stored nudges for a fill-scaled wrapper (PLAN Task 1).
18
+ *
19
+ * Stored `dx`/`dy` are COMPOSITION pixels — screen truth, what the editor
20
+ * measured. But `editStyle` renders inside `SceneLayer`'s `scale(fitScale)`
21
+ * wrapper (§23's fill contract), so an uncorrected translate moved the
22
+ * element `dx × fitScale` on screen — overshoot proportional to distance,
23
+ * which is exactly how the bug presented. The correction lives HERE, at the
24
+ * one boundary that knows the wrapper scale, so the editor stays
25
+ * scale-ignorant and a future change to the fill contract has the
26
+ * compensation sitting right next to it.
27
+ *
28
+ * `scale` nudges pass through untouched: scale composes multiplicatively, so
29
+ * the wrapper's factor cancels by itself.
30
+ */
31
+ export function compensateEdits(edits: ElementEdits, renderScale: number): ElementEdits {
32
+ if (!edits || renderScale === 1) return edits;
33
+ return Object.fromEntries(
34
+ Object.entries(edits).map(([id, e]) => [
35
+ id,
36
+ {
37
+ ...e,
38
+ ...(e.dx !== undefined ? { dx: e.dx / renderScale } : {}),
39
+ ...(e.dy !== undefined ? { dy: e.dy / renderScale } : {}),
40
+ },
41
+ ]),
42
+ );
43
+ }
44
+
45
+ export function editStyle(edits: ElementEdits, id: string): React.CSSProperties {
46
+ const e = edits?.[id];
47
+ if (!e) return {};
48
+ const parts = [`translate(${e.dx ?? 0}px, ${e.dy ?? 0}px)`];
49
+ if (e.scale !== undefined && e.scale !== 1) parts.push(`scale(${e.scale})`);
50
+ return { transform: parts.join(" ") };
51
+ }