@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,264 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import type {
|
|
4
|
+
ContentRectSegment,
|
|
5
|
+
FaceCrop,
|
|
6
|
+
SceneCue,
|
|
7
|
+
Theme,
|
|
8
|
+
ZoomSegment,
|
|
9
|
+
} from "@ossclip/core/browser";
|
|
10
|
+
import { zoomScaleAt } from "@ossclip/core/browser";
|
|
11
|
+
import { activeCueAt, backdropOpacityAt, videoSlotAt } from "./stage";
|
|
12
|
+
import {
|
|
13
|
+
contentBox,
|
|
14
|
+
contentRectAtOutput,
|
|
15
|
+
sourceFitBox,
|
|
16
|
+
type ContentCropMode,
|
|
17
|
+
type SourceFit,
|
|
18
|
+
type SpanLike,
|
|
19
|
+
} from "./content-crop";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The stage (PHASE1 §1): a solid backdrop that fades in when a scene demotes
|
|
23
|
+
* the speaker, and an animated video slot (rect, circular mask, blur, dim)
|
|
24
|
+
* that the EDL video renders inside. The children — <EdlVideo> — stay mounted
|
|
25
|
+
* at all times, so the BASE AUDIO TRACK IS CONTINUOUS regardless of layout.
|
|
26
|
+
*/
|
|
27
|
+
export const VideoStage: React.FC<{
|
|
28
|
+
cues: SceneCue[];
|
|
29
|
+
theme: Theme;
|
|
30
|
+
/** Measured face box; null/undefined falls back to the assumed selfie framing. */
|
|
31
|
+
face?: FaceCrop | null;
|
|
32
|
+
/** Micro zoom punches (FINDINGS §15), precomputed from phrase boundaries. */
|
|
33
|
+
zoomPlan?: ZoomSegment[];
|
|
34
|
+
/**
|
|
35
|
+
* Burned-in text in the source, in OUTPUT time. The crop window is nudged so
|
|
36
|
+
* it never slices one of these bands in half (FINDINGS §36).
|
|
37
|
+
*/
|
|
38
|
+
sourceTextRegions?: Array<{ y: number; h: number; startSec: number; endSec: number }>;
|
|
39
|
+
/**
|
|
40
|
+
* The source's framing over SOURCE time (PLAN Task C). Present only for a
|
|
41
|
+
* source whose framing CHANGES mid-take — a uniformly letterboxed one is
|
|
42
|
+
* cropped by ffmpeg into the mezzanine long before this runs, and passing a
|
|
43
|
+
* timeline for it would crop the same bars twice.
|
|
44
|
+
*/
|
|
45
|
+
contentTimeline?: ContentRectSegment[];
|
|
46
|
+
/** Kept spans, needed to read the timeline's SOURCE clock at an output time. */
|
|
47
|
+
spans?: SpanLike[];
|
|
48
|
+
/** The source's own pixel dimensions — the frame the timeline is measured in. */
|
|
49
|
+
sourceSize?: { width: number; height: number };
|
|
50
|
+
/**
|
|
51
|
+
* How a letterboxed stretch renders: `cover` fills the slot from the strip
|
|
52
|
+
* (face-biased crop), `fit` insets the strip whole — the fallback when the
|
|
53
|
+
* strip is too small to cover without visible softening (option (b)).
|
|
54
|
+
*/
|
|
55
|
+
contentCropMode?: ContentCropMode;
|
|
56
|
+
/**
|
|
57
|
+
* How the SOURCE meets the slot (`--source-fit`). `contain` shows the whole
|
|
58
|
+
* frame inset against the backdrop instead of cover-cropping it — the
|
|
59
|
+
* landscape escape hatch. Needs `sourceSize` to know the frame's shape;
|
|
60
|
+
* without it there is nothing to fit and this falls back to cover.
|
|
61
|
+
*/
|
|
62
|
+
sourceFit?: SourceFit;
|
|
63
|
+
children: React.ReactNode;
|
|
64
|
+
}> = ({
|
|
65
|
+
cues,
|
|
66
|
+
theme,
|
|
67
|
+
face,
|
|
68
|
+
zoomPlan,
|
|
69
|
+
sourceTextRegions,
|
|
70
|
+
contentTimeline,
|
|
71
|
+
spans,
|
|
72
|
+
sourceSize,
|
|
73
|
+
contentCropMode = "cover",
|
|
74
|
+
sourceFit = "cover",
|
|
75
|
+
children,
|
|
76
|
+
}) => {
|
|
77
|
+
const frame = useCurrentFrame();
|
|
78
|
+
const { fps, width, height } = useVideoConfig();
|
|
79
|
+
const t = frame / fps;
|
|
80
|
+
// The composition already knows its own frame — hand it to the geometry so
|
|
81
|
+
// a landscape export crops against 16:9 instead of the portrait assumption
|
|
82
|
+
// baked in when 9:16 was the only output (R15).
|
|
83
|
+
const outFrame = { width, height };
|
|
84
|
+
const slot = videoSlotAt(cues, t, face ?? undefined, sourceTextRegions ?? [], outFrame);
|
|
85
|
+
const backdrop = backdropOpacityAt(cues, t);
|
|
86
|
+
|
|
87
|
+
const wPx = slot.rect.w * width;
|
|
88
|
+
const hPx = slot.rect.h * height;
|
|
89
|
+
const radiusPx = (slot.cornerRadius * Math.min(wPx, hPx)) / 2;
|
|
90
|
+
|
|
91
|
+
// The user's per-scene crop correction (`overrides.json` -> cue.video).
|
|
92
|
+
// Composed with the idle zoom rather than replacing it, so a scene that was
|
|
93
|
+
// nudged still breathes; NOT lerped across the layout transition, because a
|
|
94
|
+
// correction belongs to one scene and interpolating it into its neighbour
|
|
95
|
+
// would drag the neighbour's crop with it.
|
|
96
|
+
const activeCue = activeCueAt(cues, t);
|
|
97
|
+
const userVideo = activeCue?.video;
|
|
98
|
+
|
|
99
|
+
// §15: the idle zoom fades with the slot (graphic-only suppresses it) and
|
|
100
|
+
// is damped on the bubble — a zooming bubble reads as a wobble. Both fall
|
|
101
|
+
// out of the already-lerped slot state, so the damping stays continuous
|
|
102
|
+
// through layout transitions. It composes with EdlVideo's cut-driven
|
|
103
|
+
// punch-in multiplicatively (nested transforms). Per scene the AUTOMATIC
|
|
104
|
+
// layer is switchable (`autoZoom: false` — PLAN 2026-07-30 Task A3):
|
|
105
|
+
// "adjust the zoomed part" = switch it off and dial your own scale, or
|
|
106
|
+
// leave it on and correct multiplicatively on top.
|
|
107
|
+
const zoomRaw = zoomPlan && zoomPlan.length > 0 ? zoomScaleAt(zoomPlan, t) : 1;
|
|
108
|
+
const zoomDamp = Math.max(0, slot.opacity * (1 - 0.6 * slot.cornerRadius));
|
|
109
|
+
const autoZoom = userVideo?.autoZoom !== false;
|
|
110
|
+
// `contain` promises the WHOLE frame; the idle push would immediately crop
|
|
111
|
+
// it back — a 1.08 scale on an exactly-fitted picture trims 8% off every
|
|
112
|
+
// edge. So the automatic layer is off in that mode by construction, not by
|
|
113
|
+
// asking the user to remember to switch it off per scene. An explicit
|
|
114
|
+
// `cue.video.scale` still applies: that one is a decision, not a default.
|
|
115
|
+
const fitContain = sourceFit === "contain" && sourceSize !== undefined;
|
|
116
|
+
const zoom = autoZoom && !fitContain ? 1 + (zoomRaw - 1) * zoomDamp : 1;
|
|
117
|
+
const userScale = userVideo?.scale ?? 1;
|
|
118
|
+
const userDx = userVideo?.dx ?? 0;
|
|
119
|
+
const userDy = userVideo?.dy ?? 0;
|
|
120
|
+
const contentTransform =
|
|
121
|
+
[
|
|
122
|
+
userDx !== 0 || userDy !== 0 ? `translate(${userDx}px, ${userDy}px)` : "",
|
|
123
|
+
zoom * userScale !== 1 ? `scale(${zoom * userScale})` : "",
|
|
124
|
+
]
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.join(" ") || undefined;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<AbsoluteFill>
|
|
130
|
+
<AbsoluteFill style={{ background: theme.bg, opacity: backdrop }} />
|
|
131
|
+
<div
|
|
132
|
+
// Which scene's framing a grab on the picture edits (PLAN 2026-07-30
|
|
133
|
+
// Task B). ATTRIBUTE ONLY — no cursor, no pointerEvents here: editor
|
|
134
|
+
// affordances stay in the editor; the renderer just states whose
|
|
135
|
+
// video this is right now.
|
|
136
|
+
data-edit-video={activeCue?.id}
|
|
137
|
+
style={{
|
|
138
|
+
position: "absolute",
|
|
139
|
+
left: `${slot.rect.x * 100}%`,
|
|
140
|
+
top: `${slot.rect.y * 100}%`,
|
|
141
|
+
width: wPx,
|
|
142
|
+
height: hPx,
|
|
143
|
+
borderRadius: radiusPx,
|
|
144
|
+
overflow: "hidden",
|
|
145
|
+
opacity: slot.opacity,
|
|
146
|
+
// Under `contain` the picture no longer reaches the slot's edges.
|
|
147
|
+
// Paint the theme's own backdrop behind it so the inset reads as a
|
|
148
|
+
// deliberate frame rather than as black bars: the root fill is
|
|
149
|
+
// black, and every layout except pip/graphic-only leaves the stage
|
|
150
|
+
// backdrop at opacity 0, so without this the gap would be black.
|
|
151
|
+
backgroundColor: fitContain ? theme.bg : undefined,
|
|
152
|
+
// Slightly lift the bubble off the backdrop like the reference.
|
|
153
|
+
boxShadow: slot.cornerRadius > 0.5 ? "0 18px 60px rgba(0,0,0,0.55)" : undefined,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<div
|
|
157
|
+
style={{
|
|
158
|
+
position: "absolute",
|
|
159
|
+
inset: 0,
|
|
160
|
+
filter: slot.blurPx > 0.5 ? `blur(${slot.blurPx}px)` : undefined,
|
|
161
|
+
transform: contentTransform,
|
|
162
|
+
// Zoom toward the face, which the crop bias keeps in the upper part.
|
|
163
|
+
transformOrigin: "50% 40%",
|
|
164
|
+
// Crop bias consumed by EdlVideo's object-position (FINDINGS §11/§13):
|
|
165
|
+
// derived from the measured face so the head lands in the band.
|
|
166
|
+
["--ossclip-obj-y" as string]: `${slot.objectPosY * 100}%`,
|
|
167
|
+
// …and the horizontal one, which only leaves centre for a source
|
|
168
|
+
// wider than the slot (a landscape take in a vertical frame).
|
|
169
|
+
["--ossclip-obj-x" as string]: `${slot.objectPosX * 100}%`,
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
{fitContain ? (
|
|
173
|
+
// The whole frame, inset and centred. The box carries the SOURCE's
|
|
174
|
+
// aspect, so EdlVideo's `object-fit: cover` has no overflow left to
|
|
175
|
+
// crop and its object-position bias becomes a no-op — the same
|
|
176
|
+
// property the mixed-framing fit path relies on. Takes precedence
|
|
177
|
+
// over the content-rect path below: `contain` means "exactly as
|
|
178
|
+
// recorded", bars in the source included.
|
|
179
|
+
<FitBox source={sourceSize!} slot={{ width: wPx, height: hPx }}>
|
|
180
|
+
{children}
|
|
181
|
+
</FitBox>
|
|
182
|
+
) : (
|
|
183
|
+
<ContentCrop
|
|
184
|
+
timeline={contentTimeline}
|
|
185
|
+
spans={spans}
|
|
186
|
+
sourceSize={sourceSize}
|
|
187
|
+
mode={contentCropMode}
|
|
188
|
+
tSec={t}
|
|
189
|
+
slot={{ width: wPx, height: hPx }}
|
|
190
|
+
posX={slot.objectPosX}
|
|
191
|
+
posY={slot.objectPosY}
|
|
192
|
+
>
|
|
193
|
+
{children}
|
|
194
|
+
</ContentCrop>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
<div style={{ position: "absolute", inset: 0, background: "black", opacity: slot.dim }} />
|
|
198
|
+
</div>
|
|
199
|
+
</AbsoluteFill>
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** Shows the whole source frame, centred inside the slot (`--source-fit contain`). */
|
|
204
|
+
const FitBox: React.FC<{
|
|
205
|
+
source: { width: number; height: number };
|
|
206
|
+
slot: { width: number; height: number };
|
|
207
|
+
children: React.ReactNode;
|
|
208
|
+
}> = ({ source, slot, children }) => {
|
|
209
|
+
const box = sourceFitBox(source, slot);
|
|
210
|
+
return (
|
|
211
|
+
<div
|
|
212
|
+
style={{ position: "absolute", width: box.width, height: box.height, left: box.left, top: box.top }}
|
|
213
|
+
>
|
|
214
|
+
{children}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Crops the video to the source's ACTIVE content rect (PLAN Task C).
|
|
221
|
+
*
|
|
222
|
+
* A no-op — literally the same `inset: 0` box as before — unless the source's
|
|
223
|
+
* framing changes mid-take AND this moment is inside a letterboxed stretch.
|
|
224
|
+
* Keeping the untouched path byte-identical is deliberate: the uniform case is
|
|
225
|
+
* what every existing geometry test covers, and it must not drift because the
|
|
226
|
+
* mixed case needed something.
|
|
227
|
+
*
|
|
228
|
+
* When it does apply, the FULL frame is sized and offset so the content rect
|
|
229
|
+
* covers the slot. The resulting box carries the source's own aspect ratio, so
|
|
230
|
+
* the video's `object-fit: cover` has no overflow left to crop and its
|
|
231
|
+
* `object-position` becomes a no-op — the bias has already been spent here.
|
|
232
|
+
*/
|
|
233
|
+
const ContentCrop: React.FC<{
|
|
234
|
+
timeline?: ContentRectSegment[];
|
|
235
|
+
spans?: SpanLike[];
|
|
236
|
+
sourceSize?: { width: number; height: number };
|
|
237
|
+
mode: ContentCropMode;
|
|
238
|
+
tSec: number;
|
|
239
|
+
slot: { width: number; height: number };
|
|
240
|
+
posX: number;
|
|
241
|
+
posY: number;
|
|
242
|
+
children: React.ReactNode;
|
|
243
|
+
}> = ({ timeline, spans, sourceSize, mode, tSec, slot, posX, posY, children }) => {
|
|
244
|
+
const passthrough = <div style={{ position: "absolute", inset: 0 }}>{children}</div>;
|
|
245
|
+
if (!timeline || timeline.length < 2 || !sourceSize) return passthrough;
|
|
246
|
+
|
|
247
|
+
const rect = contentRectAtOutput(timeline, spans ?? [], tSec, sourceSize);
|
|
248
|
+
if (rect.full) return passthrough;
|
|
249
|
+
|
|
250
|
+
const box = contentBox(mode, sourceSize, rect, slot, posX, posY);
|
|
251
|
+
return (
|
|
252
|
+
<div
|
|
253
|
+
style={{
|
|
254
|
+
position: "absolute",
|
|
255
|
+
width: box.width,
|
|
256
|
+
height: box.height,
|
|
257
|
+
left: box.left,
|
|
258
|
+
top: box.top,
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
{children}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
};
|
package/src/anim.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Entrance progress 0→1, scene-local (components are mounted inside a
|
|
6
|
+
* <Sequence> per cue, so frame 0 === the cue's start). Stagger children by
|
|
7
|
+
* passing increasing delays (~4-6 frames, PHASE1 §3).
|
|
8
|
+
*/
|
|
9
|
+
export function useEnter(delayFrames = 0): number {
|
|
10
|
+
const frame = useCurrentFrame();
|
|
11
|
+
const { fps } = useVideoConfig();
|
|
12
|
+
return spring({
|
|
13
|
+
frame: frame - delayFrames,
|
|
14
|
+
fps,
|
|
15
|
+
config: { damping: 200, stiffness: 130 },
|
|
16
|
+
durationInFrames: Math.round(fps * 0.5),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function rise(progress: number, px = 26): CSSProperties {
|
|
21
|
+
return { opacity: progress, transform: `translateY(${(1 - progress) * px}px)` };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pop(progress: number): CSSProperties {
|
|
25
|
+
return { opacity: progress, transform: `scale(${0.9 + 0.1 * progress})` };
|
|
26
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { BulletListProps, type Theme } from "@ossclip/core/browser";
|
|
4
|
+
import { rise, useEnter } from "../anim";
|
|
5
|
+
import { bulletMetrics } from "../fit";
|
|
6
|
+
import { editStyle, type ElementEdits } from "../editable";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An enumeration (R16 §67): one bullet row per listed item, optional kicker
|
|
10
|
+
* title. Self-fitting like StrikethroughReveal — rows are nowrap (a wrapped
|
|
11
|
+
* bullet stops reading as a list), so the type solves against the slot in
|
|
12
|
+
* `bulletMetrics` and the stage's uniform scale stays out of it.
|
|
13
|
+
*/
|
|
14
|
+
const Item: React.FC<{
|
|
15
|
+
text: string;
|
|
16
|
+
index: number;
|
|
17
|
+
fontSize: number;
|
|
18
|
+
theme: Theme;
|
|
19
|
+
edits?: ElementEdits;
|
|
20
|
+
}> = ({ text, index, fontSize, theme, edits }) => {
|
|
21
|
+
const editId = `item-${index}`;
|
|
22
|
+
const p = useEnter(index * 7);
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
data-edit-id={editId}
|
|
26
|
+
style={{
|
|
27
|
+
...rise(p, 26),
|
|
28
|
+
display: "flex",
|
|
29
|
+
alignItems: "baseline",
|
|
30
|
+
gap: fontSize * 0.35,
|
|
31
|
+
...editStyle(edits, editId),
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<span style={{ fontSize: fontSize * 0.75, color: theme.accent, fontWeight: 900 }}>▸</span>
|
|
35
|
+
<span
|
|
36
|
+
style={{
|
|
37
|
+
fontSize,
|
|
38
|
+
fontWeight: 900,
|
|
39
|
+
textTransform: "uppercase",
|
|
40
|
+
lineHeight: 1.15,
|
|
41
|
+
whiteSpace: "nowrap",
|
|
42
|
+
color: theme.fg,
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
{text}
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const BulletList: React.FC<{
|
|
52
|
+
props: z.infer<typeof BulletListProps>;
|
|
53
|
+
theme: Theme;
|
|
54
|
+
widthPx?: number;
|
|
55
|
+
heightPx?: number;
|
|
56
|
+
edits?: ElementEdits;
|
|
57
|
+
}> = ({ props, theme, widthPx, heightPx, edits }) => {
|
|
58
|
+
const fontSize = bulletMetrics(props.items, widthPx, heightPx, Boolean(props.title));
|
|
59
|
+
const titleEnter = useEnter(0);
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
display: "flex",
|
|
64
|
+
flexDirection: "column",
|
|
65
|
+
alignItems: "flex-start",
|
|
66
|
+
gap: fontSize * 0.45,
|
|
67
|
+
fontFamily: theme.fontDisplay,
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{props.title ? (
|
|
71
|
+
<div
|
|
72
|
+
data-edit-id="title"
|
|
73
|
+
style={{
|
|
74
|
+
...rise(titleEnter, 20),
|
|
75
|
+
fontSize: fontSize * 0.36,
|
|
76
|
+
fontWeight: 700,
|
|
77
|
+
letterSpacing: "0.28em",
|
|
78
|
+
textTransform: "uppercase",
|
|
79
|
+
color: theme.muted,
|
|
80
|
+
...editStyle(edits, "title"),
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{props.title}
|
|
84
|
+
</div>
|
|
85
|
+
) : null}
|
|
86
|
+
{props.items.map((text, i) => (
|
|
87
|
+
<Item key={i} text={text} index={i} fontSize={fontSize} theme={theme} edits={edits} />
|
|
88
|
+
))}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { ChatMockProps, type Theme } from "@ossclip/core/browser";
|
|
4
|
+
import { pop, useEnter } from "../anim";
|
|
5
|
+
import { chatBubbles, chatMetrics } from "../fit";
|
|
6
|
+
import { editStyle, type ElementEdits } from "../editable";
|
|
7
|
+
|
|
8
|
+
const Bubble: React.FC<{
|
|
9
|
+
from: "user" | "agent";
|
|
10
|
+
text: string;
|
|
11
|
+
delay: number;
|
|
12
|
+
fontSize: number;
|
|
13
|
+
theme: Theme;
|
|
14
|
+
editId: string;
|
|
15
|
+
edits?: ElementEdits;
|
|
16
|
+
}> = ({ from, text, delay, fontSize, theme, editId, edits }) => {
|
|
17
|
+
const p = useEnter(delay);
|
|
18
|
+
const mine = from === "user";
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
data-edit-id={editId}
|
|
22
|
+
style={{
|
|
23
|
+
...pop(p),
|
|
24
|
+
alignSelf: mine ? "flex-end" : "flex-start",
|
|
25
|
+
background: mine ? theme.fg : theme.cardBg,
|
|
26
|
+
color: mine ? theme.bg : theme.fg,
|
|
27
|
+
border: `2px solid ${mine ? theme.fg : theme.cardBorder}`,
|
|
28
|
+
borderRadius: 28,
|
|
29
|
+
[mine ? "borderBottomRightRadius" : "borderBottomLeftRadius"]: 8,
|
|
30
|
+
// Padding is a HARD boundary, not a suggestion: the type is sized so
|
|
31
|
+
// the longest unbreakable word fits inside bubble-minus-padding, which
|
|
32
|
+
// is what stops a single word like "AGENTS" rendering edge to edge
|
|
33
|
+
// and spilling past the rounded rect (FINDINGS §28a).
|
|
34
|
+
padding: `${fontSize * 0.6}px ${fontSize * 0.85}px`,
|
|
35
|
+
fontSize,
|
|
36
|
+
fontWeight: 700,
|
|
37
|
+
maxWidth: "82%",
|
|
38
|
+
fontFamily: theme.fontDisplay,
|
|
39
|
+
...editStyle(edits, editId),
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{text}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The comment-CTA keyword gets the reference's quote-and-caps treatment —
|
|
49
|
+
* `"AGENTS"` — wherever it appears in a message. The producer only marks the
|
|
50
|
+
* word (props.keyword); formatting lives here, never in LLM output
|
|
51
|
+
* (FINDINGS §16). Quotes the LLM already added are absorbed, not doubled.
|
|
52
|
+
*/
|
|
53
|
+
export function applyCtaKeyword(text: string, keyword: string | undefined): string {
|
|
54
|
+
if (!keyword) return text;
|
|
55
|
+
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
56
|
+
return text.replace(new RegExp(`"?\\b${escaped}\\b"?`, "gi"), `"${keyword.toUpperCase()}"`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { chatBubbles } from "../fit";
|
|
60
|
+
|
|
61
|
+
export const ChatMock: React.FC<{
|
|
62
|
+
props: z.infer<typeof ChatMockProps>;
|
|
63
|
+
theme: Theme;
|
|
64
|
+
widthPx?: number;
|
|
65
|
+
heightPx?: number;
|
|
66
|
+
edits?: ElementEdits;
|
|
67
|
+
}> = ({ props, theme, widthPx, heightPx, edits }) => {
|
|
68
|
+
const bubbles = chatBubbles(props);
|
|
69
|
+
// Self-fitting (R11 Task 3): the type solves against the REAL slot, both
|
|
70
|
+
// axes — the stage no longer magnifies this component.
|
|
71
|
+
const fontSize = chatMetrics(bubbles.map((b) => b.text), { widthPx, heightPx });
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
style={{
|
|
75
|
+
display: "flex",
|
|
76
|
+
flexDirection: "column",
|
|
77
|
+
gap: fontSize * 0.5,
|
|
78
|
+
width: "100%",
|
|
79
|
+
padding: "0 40px",
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{bubbles.map((b, i) => (
|
|
83
|
+
<Bubble
|
|
84
|
+
key={i}
|
|
85
|
+
from={b.from}
|
|
86
|
+
text={b.text}
|
|
87
|
+
delay={i * 8}
|
|
88
|
+
fontSize={fontSize}
|
|
89
|
+
theme={theme}
|
|
90
|
+
editId={`message-${i}`}
|
|
91
|
+
edits={edits}
|
|
92
|
+
/>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { FlowDiagramProps, type Theme } from "@ossclip/core/browser";
|
|
4
|
+
import { pop, useEnter } from "../anim";
|
|
5
|
+
import { flowMetrics } from "../fit";
|
|
6
|
+
import { editStyle, type ElementEdits } from "../editable";
|
|
7
|
+
|
|
8
|
+
const Chip: React.FC<{
|
|
9
|
+
text: string;
|
|
10
|
+
emphasized: boolean;
|
|
11
|
+
delay: number;
|
|
12
|
+
fontSize: number;
|
|
13
|
+
theme: Theme;
|
|
14
|
+
editId: string;
|
|
15
|
+
edits?: ElementEdits;
|
|
16
|
+
}> = ({ text, emphasized, delay, fontSize, theme, editId, edits }) => {
|
|
17
|
+
const p = useEnter(delay);
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
data-edit-id={editId}
|
|
21
|
+
style={{
|
|
22
|
+
...pop(p),
|
|
23
|
+
background: emphasized ? theme.fg : theme.cardBg,
|
|
24
|
+
color: emphasized ? theme.bg : theme.fg,
|
|
25
|
+
border: `2px solid ${emphasized ? theme.fg : theme.cardBorder}`,
|
|
26
|
+
borderRadius: theme.radiusPx / 2,
|
|
27
|
+
padding: `${fontSize * 0.55}px ${fontSize * 0.8}px`,
|
|
28
|
+
fontSize,
|
|
29
|
+
fontWeight: 900,
|
|
30
|
+
textTransform: "uppercase",
|
|
31
|
+
letterSpacing: "0.04em",
|
|
32
|
+
whiteSpace: "nowrap",
|
|
33
|
+
fontFamily: theme.fontDisplay,
|
|
34
|
+
...editStyle(edits, editId),
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
{text}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const Arrow: React.FC<{ delay: number; fontSize: number; theme: Theme; down?: boolean }> = ({
|
|
43
|
+
delay,
|
|
44
|
+
fontSize,
|
|
45
|
+
theme,
|
|
46
|
+
down = false,
|
|
47
|
+
}) => {
|
|
48
|
+
const p = useEnter(delay);
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
style={{
|
|
52
|
+
opacity: p,
|
|
53
|
+
color: theme.muted,
|
|
54
|
+
fontSize: fontSize * 1.15,
|
|
55
|
+
fontWeight: 700,
|
|
56
|
+
lineHeight: 1,
|
|
57
|
+
paddingLeft: down ? 0 : fontSize * 0.55,
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{down ? "↓" : "→"}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Row vs stack, decided from content (FINDINGS §1/§12): fit-to-width scales
|
|
67
|
+
* the type down for a single row, and when real copy can't fit even at the
|
|
68
|
+
* font floor — the golden fixture's short labels hid this — the diagram
|
|
69
|
+
* becomes a vertical stack with downward arrows instead of ever wrapping.
|
|
70
|
+
*
|
|
71
|
+
* The width budget is the container's, not a constant: the stage now scales
|
|
72
|
+
* every graphic to fill its slot (§23), so the diagram is laid out at a width
|
|
73
|
+
* derived from that scale rather than against a hardcoded 820px that no longer
|
|
74
|
+
* corresponds to any slot. The old row/stack font caps are gone with it —
|
|
75
|
+
* capping the type here is exactly what left the diagram an 8%-tall strip.
|
|
76
|
+
*/
|
|
77
|
+
export function flowLayout(
|
|
78
|
+
nodes: readonly string[],
|
|
79
|
+
widthPx = DEFAULT_FLOW_WIDTH_PX,
|
|
80
|
+
heightPx?: number,
|
|
81
|
+
): { mode: "row" | "stack"; fontSize: number } {
|
|
82
|
+
return flowMetrics(nodes, widthPx, heightPx);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fallback when the stage isn't supplying a budget (tests, direct use). */
|
|
86
|
+
const DEFAULT_FLOW_WIDTH_PX = 864;
|
|
87
|
+
|
|
88
|
+
export const FlowDiagram: React.FC<{
|
|
89
|
+
props: z.infer<typeof FlowDiagramProps>;
|
|
90
|
+
theme: Theme;
|
|
91
|
+
/** The slot this component must fill — see flowMetrics (FINDINGS §23). */
|
|
92
|
+
widthPx?: number;
|
|
93
|
+
heightPx?: number;
|
|
94
|
+
edits?: ElementEdits;
|
|
95
|
+
}> = ({ props, theme, widthPx, heightPx, edits }) => {
|
|
96
|
+
// The stage hands down the budget; no measurement needed because the slot
|
|
97
|
+
// geometry is known up front.
|
|
98
|
+
const { mode, fontSize } = flowLayout(props.nodes, widthPx, heightPx);
|
|
99
|
+
const row = mode === "row";
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
style={{
|
|
103
|
+
display: "flex",
|
|
104
|
+
flexDirection: row ? "row" : "column",
|
|
105
|
+
flexWrap: "nowrap",
|
|
106
|
+
alignItems: "center",
|
|
107
|
+
justifyContent: "center",
|
|
108
|
+
gap: row ? 0 : fontSize * 0.45,
|
|
109
|
+
padding: "0 10px",
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
{props.nodes.map((node, i) => (
|
|
113
|
+
// Arrow and its target chip are ONE flex item, and the arrow enters
|
|
114
|
+
// AFTER its chip — an arrow into nothing is impossible in either shape.
|
|
115
|
+
<div
|
|
116
|
+
key={i}
|
|
117
|
+
style={{
|
|
118
|
+
display: "flex",
|
|
119
|
+
flexDirection: row ? "row" : "column",
|
|
120
|
+
alignItems: "center",
|
|
121
|
+
gap: row ? fontSize * 0.55 : fontSize * 0.45,
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
{i > 0 ? <Arrow delay={i * 6 + 3} fontSize={fontSize} theme={theme} down={!row} /> : null}
|
|
125
|
+
<Chip
|
|
126
|
+
text={node}
|
|
127
|
+
emphasized={props.emphasizeLast && i === props.nodes.length - 1}
|
|
128
|
+
delay={i * 6}
|
|
129
|
+
fontSize={fontSize}
|
|
130
|
+
theme={theme}
|
|
131
|
+
editId={`node-${i}`}
|
|
132
|
+
edits={edits}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
};
|