@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
|
@@ -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
|
+
}
|
package/src/editable.ts
ADDED
|
@@ -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
|
+
}
|