@crievo/omni 0.1.2 → 0.1.4
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/docs/onboarding.md +69 -161
- package/omnivideo.config.example.json +1 -1
- package/package.json +2 -1
- package/reference/learnings.md +12 -0
- package/reference/omnichat.md +5 -4
- package/reference/publish.md +9 -9
- package/reference/studio.md +1 -1
- package/studio/api/app.ts +2 -2
- package/studio/api/routes/sound.ts +22 -3
- package/studio/api/services/onboarding.ts +242 -21
- package/studio/api/services/settings.ts +4 -2
- package/studio/api/services/sound.ts +45 -0
- package/studio/shared/types/api.ts +26 -1
- package/studio/studio.ts +1 -0
- package/studio/web/components/AppShell.tsx +3 -1
- package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
- package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
- package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
- package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
- package/studio/web/components/Omni/OmniWidget.tsx +14 -5
- package/studio/web/components/Omni/PendingActions.tsx +25 -6
- package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
- package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
- package/studio/web/components/ResultMessage.tsx +12 -3
- package/studio/web/components/Sidebar.tsx +63 -24
- package/studio/web/components/StageStepper.tsx +105 -34
- package/studio/web/components/Toaster.tsx +25 -0
- package/studio/web/components/VoiceCarousel.tsx +19 -16
- package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
- package/studio/web/components/bencho/ActionNode.tsx +456 -0
- package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
- package/studio/web/components/bencho/BenchoScope.tsx +8 -0
- package/studio/web/components/bencho/Checklist.tsx +421 -0
- package/studio/web/components/bencho/CreateMenu.tsx +258 -0
- package/studio/web/components/bencho/GenerateImage.tsx +290 -0
- package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
- package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
- package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
- package/studio/web/components/bencho/NowPlaying.tsx +403 -0
- package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
- package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
- package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
- package/studio/web/components/bencho/SloshSlider.tsx +394 -0
- package/studio/web/components/bencho/StackedBars.tsx +342 -0
- package/studio/web/components/bencho/ToastStack.tsx +263 -0
- package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
- package/studio/web/components/bencho/VoiceNote.tsx +705 -0
- package/studio/web/components/icons.tsx +27 -0
- package/studio/web/components/ui/slider.tsx +34 -1
- package/studio/web/components/ui/switch.tsx +18 -5
- package/studio/web/hooks/useOnboarding.ts +24 -6
- package/studio/web/index.html +1 -1
- package/studio/web/lib/alerts.ts +10 -0
- package/studio/web/lib/omni-panel.ts +4 -1
- package/studio/web/lib/toast.ts +196 -0
- package/studio/web/lib/upload.ts +24 -0
- package/studio/web/pages/BenchoPage/index.tsx +74 -0
- package/studio/web/pages/BenchoPage/registry.ts +174 -0
- package/studio/web/pages/GettingStartedPage/components/ComputerCard.tsx +102 -0
- package/studio/web/pages/GettingStartedPage/index.tsx +84 -58
- package/studio/web/pages/NewVideoPage/index.tsx +6 -2
- package/studio/web/pages/ReferencesPage/index.tsx +4 -1
- package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +30 -3
- package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
- package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
- package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
- package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
- package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
- package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
- package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
- package/studio/web/pages/VideoPage/index.tsx +4 -0
- package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
- package/studio/web/router.tsx +8 -0
- package/studio/web/styles/components/bencho/action-node.css +227 -0
- package/studio/web/styles/components/bencho/checklist.css +182 -0
- package/studio/web/styles/components/bencho/dock.css +188 -0
- package/studio/web/styles/components/bencho/gallery.css +109 -0
- package/studio/web/styles/components/bencho/generate.css +151 -0
- package/studio/web/styles/components/bencho/image-accordion.css +37 -0
- package/studio/web/styles/components/bencho/liq-create.css +279 -0
- package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
- package/studio/web/styles/components/bencho/one-time-code.css +148 -0
- package/studio/web/styles/components/bencho/picker.css +204 -0
- package/studio/web/styles/components/bencho/pull.css +313 -0
- package/studio/web/styles/components/bencho/scope.css +42 -0
- package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
- package/studio/web/styles/components/bencho/slosh.css +137 -0
- package/studio/web/styles/components/bencho/sound.css +260 -0
- package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
- package/studio/web/styles/components/bencho/toast-stack.css +192 -0
- package/studio/web/styles/components/bencho/tokens.css +243 -0
- package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
- package/studio/web/styles/components/bencho/voice-note.css +337 -0
- package/studio/web/styles/components/clone-voice.css +12 -2
- package/studio/web/styles/components/omni.css +6 -1
- package/studio/web/styles/components/stepper.css +123 -7
- package/studio/web/styles/components/toaster.css +10 -0
- package/studio/web/styles/components/ui/slider.css +6 -16
- package/studio/web/styles/components/ui/switch.css +2 -41
- package/studio/web/styles/components/voice-carousel.css +46 -4
- package/studio/web/styles/index.css +23 -0
- package/studio/web/styles/pages/getting-started.css +46 -0
- package/studio/web/styles/pages/settings/shell.css +4 -0
- package/studio/web/styles/pages/video/layout.css +1 -4
- package/studio/web/styles/pages/video/sound-timeline.css +8 -0
- package/studio/web/styles/theme/identity/shell.css +10 -26
- package/tools/lib/agents.mjs +24 -13
- package/tools/lib/claude-settings.mjs +32 -3
- package/tools/lib/deps.mjs +24 -0
- package/tools/lib/platforms/instagram.mjs +1 -1
- package/tools/lib/up.mjs +5 -2
- package/tools/omni.mjs +7 -5
- package/tools/omnichat.mjs +5 -7
- package/tools/package.mjs +5 -3
- package/tools/publish.mjs +40 -137
- package/tools/reference.mjs +1 -1
- package/tools/setup.mjs +16 -15
- package/tools/sound.mjs +67 -3
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/* A pill that spreads into the create menu it was all along. One shape and no goo filter on it: the other liquid
|
|
2
|
+
studies fuse two or more shapes, this has exactly one, so a blur could only damage it. The liquid here is entirely in
|
|
3
|
+
the timing: the press sinks, and the spread starts while the sink is still running so there is never a still frame.
|
|
4
|
+
Ported from bencho.dev (MIT).
|
|
5
|
+
|
|
6
|
+
Two layers: the opaque body (animated width, height and corner by Motion) and, above it and unfiltered, the label
|
|
7
|
+
and the rows, so the type keeps its antialiasing. The pill's width is measured from its own label and written back
|
|
8
|
+
as --pill-w, so the label can change without the shape jumping. */
|
|
9
|
+
|
|
10
|
+
import { type ComponentType, type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
11
|
+
import { motion } from "motion/react";
|
|
12
|
+
import { FileText, GridFour, Play, Plus, Sparkle } from "../icons";
|
|
13
|
+
|
|
14
|
+
export type CreateMenuItem = {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
icon: ComponentType<{ size?: number; weight?: "bold" }>;
|
|
18
|
+
onSelect: () => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/* the elastic: zeta = 14 / (2 * sqrt(220 * 0.5)) is about 0.67, so it overshoots about 6% before it settles */
|
|
22
|
+
const LIQUID = {
|
|
23
|
+
type: "spring" as const,
|
|
24
|
+
stiffness: 220,
|
|
25
|
+
damping: 14,
|
|
26
|
+
mass: 0.5,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/* The same liquid without the overshoot, for the shape whose corner radius is animating: a spring that passes its
|
|
30
|
+
target drags the radius past it too, and corners that dip under and come back read as a wobble. Not just more
|
|
31
|
+
damping (that creeps, about 470ms to settle): stiffness comes up with it, which holds zeta at 1.03 and lands in
|
|
32
|
+
about 180ms. */
|
|
33
|
+
const LIQUID_STILL = { ...LIQUID, stiffness: 420, damping: 30 };
|
|
34
|
+
|
|
35
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
36
|
+
|
|
37
|
+
/* what the gallery shows; a real screen passes its own `items` */
|
|
38
|
+
const DEMO_ITEMS: CreateMenuItem[] = [
|
|
39
|
+
{ id: "video", icon: Play, label: "Vídeo", onSelect: () => {} },
|
|
40
|
+
{ id: "carousel", icon: GridFour, label: "Carrossel", onSelect: () => {} },
|
|
41
|
+
{ id: "style", icon: Sparkle, label: "Estilo visual", onSelect: () => {} },
|
|
42
|
+
{ id: "script", icon: FileText, label: "Roteiro", onSelect: () => {} },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/* The open panel is as wide as it was drawn (212) and as tall as the rows it holds: a 34px row, the 2px gap between
|
|
46
|
+
rows and the 12px padding top and bottom. Four rows come to 166, the number the original was drawn at. */
|
|
47
|
+
const PANEL_W = 212;
|
|
48
|
+
const ROW_H = 34;
|
|
49
|
+
const ROW_GAP = 2;
|
|
50
|
+
const PANEL_PAD = 12;
|
|
51
|
+
const panelHeight = (rows: number) => rows * ROW_H + Math.max(0, rows - 1) * ROW_GAP + PANEL_PAD * 2;
|
|
52
|
+
/* The centred stage is the panel plus the ground the shape needs to travel: 244 wide, and 78 taller than the panel
|
|
53
|
+
(244 for four rows). */
|
|
54
|
+
const STAGE_W = 244;
|
|
55
|
+
const STAGE_EXTRA = 78;
|
|
56
|
+
|
|
57
|
+
/* The menu's corners, three of them: the panel is 28, the rows inside it are cut to that less the 10px inset
|
|
58
|
+
(concentric), and the shut pill is a 38px button, so 19 is round for it. One knob moves all three because they are
|
|
59
|
+
one relationship; each is capped at half its own height. */
|
|
60
|
+
const CRT_CORNER = 20;
|
|
61
|
+
const CRT_MAX = 40;
|
|
62
|
+
|
|
63
|
+
type CreateMenuProps = {
|
|
64
|
+
corner?: number;
|
|
65
|
+
/* the shut pill's word */
|
|
66
|
+
label?: string;
|
|
67
|
+
items?: CreateMenuItem[];
|
|
68
|
+
/* the shut pill's height; its corner and the type scale with it only through `corner` (default 38) */
|
|
69
|
+
pillHeight?: number;
|
|
70
|
+
/* "center": the shape grows about the middle of a fixed stage, as in the original. "top-end": the stage is just the
|
|
71
|
+
pill, in flow, and the menu hangs from its top right corner, for a pill that sits at the edge of a column. */
|
|
72
|
+
anchor?: "center" | "top-end";
|
|
73
|
+
className?: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function CreateMenu({
|
|
77
|
+
corner = CRT_CORNER,
|
|
78
|
+
label = "Criar",
|
|
79
|
+
items = DEMO_ITEMS,
|
|
80
|
+
pillHeight = 38,
|
|
81
|
+
anchor = "center",
|
|
82
|
+
className,
|
|
83
|
+
}: CreateMenuProps) {
|
|
84
|
+
const [open, setOpen] = useState(false);
|
|
85
|
+
const [sink, setSink] = useState(false);
|
|
86
|
+
|
|
87
|
+
const panel = useRef<HTMLDivElement | null>(null);
|
|
88
|
+
const pill = useRef<HTMLButtonElement | null>(null);
|
|
89
|
+
const say = useRef<HTMLSpanElement | null>(null);
|
|
90
|
+
const beat = useRef(0);
|
|
91
|
+
|
|
92
|
+
const [sayW, setSayW] = useState(96);
|
|
93
|
+
/* Measure twice: once now, once the font is real. The label clips what it cannot fit, so a width even slightly
|
|
94
|
+
short takes the edge off the last letter. offsetWidth is an integer and the label is not ("Criar" measures
|
|
95
|
+
fractional), so +1 covers the rounding; and a layout effect runs before a webfont swaps in, so it measures again
|
|
96
|
+
when fonts are ready. */
|
|
97
|
+
useLayoutEffect(() => {
|
|
98
|
+
const el = say.current;
|
|
99
|
+
if (!el) return;
|
|
100
|
+
let live = true;
|
|
101
|
+
const measure = () => {
|
|
102
|
+
if (live) setSayW(el.offsetWidth + 1);
|
|
103
|
+
};
|
|
104
|
+
measure();
|
|
105
|
+
document.fonts?.ready.then(measure).catch(() => {});
|
|
106
|
+
return () => {
|
|
107
|
+
live = false;
|
|
108
|
+
};
|
|
109
|
+
}, [label]);
|
|
110
|
+
|
|
111
|
+
useEffect(
|
|
112
|
+
() => () => {
|
|
113
|
+
window.clearTimeout(beat.current);
|
|
114
|
+
},
|
|
115
|
+
[],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
/* No close button and no title bar: a press anywhere but the menu puts it away. Registered only while open, in an
|
|
119
|
+
effect, so the very press that opened it cannot close it on the same click. */
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!open) return;
|
|
122
|
+
const away = (e: PointerEvent) => {
|
|
123
|
+
/* a press by somebody, not by the page: scripted presses on other components must not slam this one shut */
|
|
124
|
+
if (!e.isTrusted) return;
|
|
125
|
+
/* Hit-test the visible panel's own rectangle, not the reserved stage: the stage is bigger than the menu it
|
|
126
|
+
holds, so containment would count every press in the spare ground as a press on the menu. Read per press,
|
|
127
|
+
since the panel is a different size on every frame of the open. */
|
|
128
|
+
const box = panel.current?.querySelector(".bn-crt-panel")?.getBoundingClientRect();
|
|
129
|
+
const on =
|
|
130
|
+
!!box && e.clientX >= box.left && e.clientX <= box.right && e.clientY >= box.top && e.clientY <= box.bottom;
|
|
131
|
+
if (!on) {
|
|
132
|
+
setOpen(false);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
document.addEventListener("pointerdown", away);
|
|
136
|
+
return () => document.removeEventListener("pointerdown", away);
|
|
137
|
+
}, [open]);
|
|
138
|
+
|
|
139
|
+
/* Escape puts it away and hands focus back to the pill; the first row takes focus as it opens, so Enter and the
|
|
140
|
+
arrow-free Tab order start inside the menu. preventScroll: the panel is still growing when this runs. */
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!open) return;
|
|
143
|
+
panel.current?.querySelector<HTMLButtonElement>(".bn-crt-item button")?.focus({ preventScroll: true });
|
|
144
|
+
const esc = (e: KeyboardEvent) => {
|
|
145
|
+
if (e.key !== "Escape") return;
|
|
146
|
+
setOpen(false);
|
|
147
|
+
pill.current?.focus({ preventScroll: true });
|
|
148
|
+
};
|
|
149
|
+
document.addEventListener("keydown", esc);
|
|
150
|
+
return () => document.removeEventListener("keydown", esc);
|
|
151
|
+
}, [open]);
|
|
152
|
+
|
|
153
|
+
const press = () => {
|
|
154
|
+
setSink(true);
|
|
155
|
+
window.clearTimeout(beat.current);
|
|
156
|
+
/* 60ms handoff against a 120ms sink: the spread begins while the press is still running */
|
|
157
|
+
beat.current = window.setTimeout(() => {
|
|
158
|
+
setSink(false);
|
|
159
|
+
setOpen(true);
|
|
160
|
+
}, 60);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/* glyph + gap + label + the padding either side */
|
|
164
|
+
const pillW = 18 + 8 + sayW + 34;
|
|
165
|
+
|
|
166
|
+
const panelH = panelHeight(items.length);
|
|
167
|
+
/* the two the animation needs as numbers: Motion writes borderRadius inline, so it cannot read a css variable */
|
|
168
|
+
const panelR = clamp(corner, 0, CRT_MAX);
|
|
169
|
+
const shutHalf = pillHeight / 2;
|
|
170
|
+
const shutR = Math.min(shutHalf, panelR * (shutHalf / CRT_CORNER));
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<motion.div
|
|
174
|
+
className={className ? `bn-crt-stage ${className}` : "bn-crt-stage"}
|
|
175
|
+
data-open={open || undefined}
|
|
176
|
+
data-sink={sink || undefined}
|
|
177
|
+
data-anchor={anchor === "center" ? undefined : anchor}
|
|
178
|
+
ref={panel}
|
|
179
|
+
/* tabbing to another element puts it away too; focus moving between its own rows does not, and neither does a
|
|
180
|
+
press on the panel's padding (relatedTarget is null there, and the outside-press listener covers real exits) */
|
|
181
|
+
onBlur={e => {
|
|
182
|
+
const to = e.relatedTarget as Node | null;
|
|
183
|
+
if (open && to && !e.currentTarget.contains(to)) setOpen(false);
|
|
184
|
+
}}
|
|
185
|
+
style={
|
|
186
|
+
{
|
|
187
|
+
"--pill-w": `${pillW}px`,
|
|
188
|
+
"--pill-h": `${pillHeight}px`,
|
|
189
|
+
"--panel-h": `${panelH}px`,
|
|
190
|
+
"--stage-h": `${panelH + STAGE_EXTRA}px`,
|
|
191
|
+
"--crt-r": `${clamp(corner, 0, CRT_MAX)}px`,
|
|
192
|
+
/* the shut pill's own half height is as round as it goes */
|
|
193
|
+
"--crt-shut-r": `${shutR.toFixed(2)}px`,
|
|
194
|
+
/* concentric: the panel's corner less its 10px inset, and no rounder than the 34px row's own half */
|
|
195
|
+
"--crt-row-r": `${Math.min(17, Math.max(0, clamp(corner, 0, CRT_MAX) - 10)).toFixed(2)}px`,
|
|
196
|
+
} as CSSProperties
|
|
197
|
+
}
|
|
198
|
+
>
|
|
199
|
+
<div className="bn-crt-blobs" aria-hidden="true">
|
|
200
|
+
{/* LIQUID_STILL, not LIQUID: this is the one shape here that must not overshoot */}
|
|
201
|
+
<motion.div
|
|
202
|
+
className="bn-crt-body"
|
|
203
|
+
animate={{
|
|
204
|
+
width: open ? PANEL_W : pillW,
|
|
205
|
+
height: open ? panelH : pillHeight,
|
|
206
|
+
/* Motion writes this inline every frame, so the stylesheet's var(--r) never gets a look in: the shape
|
|
207
|
+
is told separately, with the same two numbers the vars carry */
|
|
208
|
+
borderRadius: open ? panelR : shutR,
|
|
209
|
+
}}
|
|
210
|
+
transition={LIQUID_STILL}
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
className="bn-crt-pill"
|
|
217
|
+
ref={pill}
|
|
218
|
+
onClick={press}
|
|
219
|
+
aria-expanded={open}
|
|
220
|
+
aria-haspopup="true"
|
|
221
|
+
>
|
|
222
|
+
<span className="bn-crt-glyph" aria-hidden="true">
|
|
223
|
+
<Plus size={16} weight="bold" />
|
|
224
|
+
</span>
|
|
225
|
+
<span className="bn-crt-say" style={{ width: sayW || undefined }}>
|
|
226
|
+
<span ref={say}>{label}</span>
|
|
227
|
+
</span>
|
|
228
|
+
</button>
|
|
229
|
+
|
|
230
|
+
<div className="bn-crt-panel" aria-hidden={!open || undefined}>
|
|
231
|
+
{/* each row lights its own wash (see .bn-crt-item button::before); nothing travels between them */}
|
|
232
|
+
<ul className="bn-crt-menu">
|
|
233
|
+
{items.map((it, i) => (
|
|
234
|
+
<li
|
|
235
|
+
className="bn-crt-item"
|
|
236
|
+
key={it.id}
|
|
237
|
+
/* tightened with the shell: the last row must not start after the box has finished in 180 */
|
|
238
|
+
style={{ transitionDelay: open ? `${30 + i * 22}ms` : "0ms" }}
|
|
239
|
+
>
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
/* shut, the rows are still in the DOM (faded out): keep them out of the Tab order */
|
|
243
|
+
tabIndex={open ? 0 : -1}
|
|
244
|
+
onClick={() => {
|
|
245
|
+
setOpen(false);
|
|
246
|
+
it.onSelect();
|
|
247
|
+
}}
|
|
248
|
+
>
|
|
249
|
+
<it.icon size={16} weight="bold" />
|
|
250
|
+
{it.label}
|
|
251
|
+
</button>
|
|
252
|
+
</li>
|
|
253
|
+
))}
|
|
254
|
+
</ul>
|
|
255
|
+
</div>
|
|
256
|
+
</motion.div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/* Generate: a picture that is made in front of you, slowly. Press Generate and the flower arrives the way a generated
|
|
2
|
+
image does, the large decisions first and the fine ones last, under a glassy liquid surface that stills as it lands.
|
|
3
|
+
Ported from bencho.dev (MIT).
|
|
4
|
+
|
|
5
|
+
Three scales of detail, in order:
|
|
6
|
+
- shapes: heavily blurred and pushed hard in contrast, the flower is a few clean masses (where the dark is, nothing
|
|
7
|
+
more). The contrast is what makes them shapes rather than fog.
|
|
8
|
+
- form: the blur lets go on a slow curve and the contrast eases back to the print's own, so the petals find their
|
|
9
|
+
edges and tones. A low drifting ripple keeps the surface liquid while it happens.
|
|
10
|
+
- grain: the halftone only comes in over the last quarter, through the print itself. The finest detail is the last
|
|
11
|
+
thing decided, which reads as generated rather than revealed.
|
|
12
|
+
(A version built from halftone dots in passes, coarse to fine, read as pixels arriving, and was too busy.)
|
|
13
|
+
|
|
14
|
+
Identity at rest: the filter only exists while a run is going, so at rest the card is the print and nothing else. */
|
|
15
|
+
|
|
16
|
+
import { type CSSProperties, useEffect, useId, useRef, useState } from "react";
|
|
17
|
+
|
|
18
|
+
/* read once, as a preference and not a live input */
|
|
19
|
+
const stillness = () =>
|
|
20
|
+
typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
21
|
+
|
|
22
|
+
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
|
|
23
|
+
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
24
|
+
const smooth = (a: number, b: number, v: number) => {
|
|
25
|
+
const u = clamp((v - a) / (b - a), 0, 1);
|
|
26
|
+
return u * u * (3 - 2 * u);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/* the print's paper, as a luminance. Contrast turns about this and not mid-grey: about 0.5 the paper went to white
|
|
30
|
+
and the card was a glowing sheet that snapped back to grey at the end. About the paper, the ground holds and the
|
|
31
|
+
ink deepens */
|
|
32
|
+
const PAPER = 0.9;
|
|
33
|
+
|
|
34
|
+
/* The count keeps its own time: a number climbing at one steady rate reads as a timer, not as work. So it runs on a
|
|
35
|
+
curve of its own, re-rolled every run (a quick start, a slow stretch, a burst, a stall, another burst, a crawl
|
|
36
|
+
through the last tenth), while the picture forms on the plain clock underneath. Knots are (time, value) pairs, both
|
|
37
|
+
0..1 and both rising, so the count never goes backwards. Between two knots it is half linear and half smoothstep:
|
|
38
|
+
speed changes read as easing rather than corners, and never quite stop. */
|
|
39
|
+
type Knot = [number, number];
|
|
40
|
+
const r = (a: number, b: number) => a + Math.random() * (b - a);
|
|
41
|
+
const knots = (): Knot[] => [
|
|
42
|
+
[0, 0],
|
|
43
|
+
[r(0.1, 0.16), r(0.1, 0.16)] /* quick start */,
|
|
44
|
+
[r(0.32, 0.38), r(0.22, 0.3)] /* slow stretch */,
|
|
45
|
+
[r(0.44, 0.49), r(0.5, 0.6)] /* burst */,
|
|
46
|
+
[r(0.56, 0.61), r(0.62, 0.68)] /* stall */,
|
|
47
|
+
[r(0.68, 0.72), r(0.84, 0.88)] /* burst */,
|
|
48
|
+
[1, 1] /* and the long last bit */,
|
|
49
|
+
];
|
|
50
|
+
const along = (ks: Knot[], t: number) => {
|
|
51
|
+
for (let i = 1; i < ks.length; i++) {
|
|
52
|
+
const [t0, v0] = ks[i - 1];
|
|
53
|
+
const [t1, v1] = ks[i];
|
|
54
|
+
if (t > t1) continue;
|
|
55
|
+
const u = clamp((t - t0) / (t1 - t0), 0, 1);
|
|
56
|
+
return mix(v0, v1, mix(u, u * u * (3 - 2 * u), 0.5));
|
|
57
|
+
}
|
|
58
|
+
return 1;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/* The picture that resolves: a flower printed in halftone on warm grey paper, drawn in SVG (3:4, `slice` so the
|
|
62
|
+
frame trims a sliver of paper top and bottom rather than squashing it). Broad masses first (petals, centre, stem),
|
|
63
|
+
rims and the dot screen last, so what the filter reveals late is the fine detail. */
|
|
64
|
+
const PETAL = "M0 0C-26-22-22-62 0-84C22-62 26-22 0 0Z";
|
|
65
|
+
const OUTER = Array.from({ length: 10 }, (_, i) => i * 36);
|
|
66
|
+
const INNER = Array.from({ length: 8 }, (_, i) => 18 + i * 45);
|
|
67
|
+
|
|
68
|
+
function Flower({ uid, label, style }: { uid: string; label?: string; style?: CSSProperties }) {
|
|
69
|
+
const rim = `${uid}-rim`;
|
|
70
|
+
const deep = `${uid}-deep`;
|
|
71
|
+
const dots = `${uid}-dots`;
|
|
72
|
+
const stem = `${uid}-stem`;
|
|
73
|
+
return (
|
|
74
|
+
<svg
|
|
75
|
+
className="bn-gen-print"
|
|
76
|
+
viewBox="0 0 270 360"
|
|
77
|
+
preserveAspectRatio="xMidYMid slice"
|
|
78
|
+
role={label ? "img" : undefined}
|
|
79
|
+
aria-label={label}
|
|
80
|
+
aria-hidden={label ? undefined : true}
|
|
81
|
+
focusable="false"
|
|
82
|
+
style={style}
|
|
83
|
+
>
|
|
84
|
+
<defs>
|
|
85
|
+
<linearGradient id={rim} x1="0" y1="0" x2="0" y2="-84" gradientUnits="userSpaceOnUse">
|
|
86
|
+
<stop offset="0" stopColor="#242426" />
|
|
87
|
+
<stop offset="0.5" stopColor="#5c5a58" />
|
|
88
|
+
<stop offset="1" stopColor="#aaa6a0" />
|
|
89
|
+
</linearGradient>
|
|
90
|
+
<linearGradient id={deep} x1="0" y1="0" x2="0" y2="-60" gradientUnits="userSpaceOnUse">
|
|
91
|
+
<stop offset="0" stopColor="#131315" />
|
|
92
|
+
<stop offset="0.6" stopColor="#3b3a3a" />
|
|
93
|
+
<stop offset="1" stopColor="#77746f" />
|
|
94
|
+
</linearGradient>
|
|
95
|
+
<linearGradient id={stem} x1="0" y1="0" x2="1" y2="0">
|
|
96
|
+
<stop offset="0" stopColor="#2c2c2d" />
|
|
97
|
+
<stop offset="1" stopColor="#555452" />
|
|
98
|
+
</linearGradient>
|
|
99
|
+
<pattern id={dots} width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(22)">
|
|
100
|
+
<circle cx="2.5" cy="2.5" r="1.15" fill="#e9e6e1" opacity="0.55" />
|
|
101
|
+
</pattern>
|
|
102
|
+
</defs>
|
|
103
|
+
<rect width="270" height="360" fill="#e7e4df" />
|
|
104
|
+
{/* stem and leaves */}
|
|
105
|
+
<path d="M133 218C128 262 142 306 136 360H146C152 306 138 262 141 218Z" fill={`url(#${stem})`} />
|
|
106
|
+
<path d="M137 296C170 286 196 262 206 232C176 238 150 262 137 296Z" fill="#3a3a3b" />
|
|
107
|
+
<path d="M136 316C106 308 82 290 70 262C100 266 126 288 136 316Z" fill="#4a4948" />
|
|
108
|
+
{/* petals: the outer ring lighter, the inner ring deeper */}
|
|
109
|
+
<g transform="translate(135 150)">
|
|
110
|
+
{OUTER.map(a => (
|
|
111
|
+
<path
|
|
112
|
+
key={a}
|
|
113
|
+
d={PETAL}
|
|
114
|
+
transform={`rotate(${a})`}
|
|
115
|
+
fill={`url(#${rim})`}
|
|
116
|
+
stroke="#ece9e4"
|
|
117
|
+
strokeOpacity="0.55"
|
|
118
|
+
strokeWidth="1.2"
|
|
119
|
+
/>
|
|
120
|
+
))}
|
|
121
|
+
{INNER.map(a => (
|
|
122
|
+
<path
|
|
123
|
+
key={a}
|
|
124
|
+
d={PETAL}
|
|
125
|
+
transform={`rotate(${a}) scale(0.72)`}
|
|
126
|
+
fill={`url(#${deep})`}
|
|
127
|
+
stroke="#ece9e4"
|
|
128
|
+
strokeOpacity="0.35"
|
|
129
|
+
strokeWidth="1.4"
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
<circle r="20" fill="#0f0f10" />
|
|
133
|
+
<circle r="13" fill="none" stroke="#6a6866" strokeOpacity="0.7" strokeWidth="1.4" strokeDasharray="1.5 3.2" />
|
|
134
|
+
</g>
|
|
135
|
+
{/* the screen: dots of paper over everything, the halftone grain */}
|
|
136
|
+
<rect width="270" height="360" fill={`url(#${dots})`} />
|
|
137
|
+
</svg>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function GenerateImage({
|
|
142
|
+
/* seconds from the press to the picture */
|
|
143
|
+
pace = 5,
|
|
144
|
+
/* how liquid the surface is while it forms, 0..100 */
|
|
145
|
+
ripple = 50,
|
|
146
|
+
/* the frame's corner, px. The button is a pill at every setting: the frame is the picture, the pill is a control */
|
|
147
|
+
corner = 20,
|
|
148
|
+
}: {
|
|
149
|
+
pace?: number;
|
|
150
|
+
ripple?: number;
|
|
151
|
+
corner?: number;
|
|
152
|
+
} = {}) {
|
|
153
|
+
const still = stillness();
|
|
154
|
+
const uid = useId().replace(/:/g, "");
|
|
155
|
+
const id = `bn-gen-${uid}`;
|
|
156
|
+
const [t, setT] = useState(1);
|
|
157
|
+
const raf = useRef(0);
|
|
158
|
+
const curve = useRef<Knot[]>(knots());
|
|
159
|
+
const busy = t < 1;
|
|
160
|
+
/* 100 is held for a beat: the run ending and the pill reading "Gerar" again on the same frame would mean nobody
|
|
161
|
+
ever sees 100, so the pill stays a loader a moment after the picture lands */
|
|
162
|
+
const [held, setHeld] = useState(false);
|
|
163
|
+
/* it starts empty: a block about making a picture that already has the picture in it has answered its own question
|
|
164
|
+
before anybody pressed it. Once made the print stays, and pressing again remakes it from blank */
|
|
165
|
+
const [made, setMade] = useState(false);
|
|
166
|
+
const hold = useRef(0);
|
|
167
|
+
const loading = busy || held;
|
|
168
|
+
/* 99 at most until the run has finished: the 100 belongs to the picture landing, not to the curve */
|
|
169
|
+
const pct = held ? 100 : Math.min(99, Math.floor(along(curve.current, t) * 100));
|
|
170
|
+
|
|
171
|
+
useEffect(
|
|
172
|
+
() => () => {
|
|
173
|
+
cancelAnimationFrame(raf.current);
|
|
174
|
+
raf.current = 0;
|
|
175
|
+
window.clearTimeout(hold.current);
|
|
176
|
+
},
|
|
177
|
+
[],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const generate = () => {
|
|
181
|
+
if (loading) return;
|
|
182
|
+
const total = still ? 400 : Math.max(2, pace) * 1000;
|
|
183
|
+
let t0 = 0;
|
|
184
|
+
const tick = (now: number) => {
|
|
185
|
+
if (!t0) t0 = now;
|
|
186
|
+
const v = Math.min(1, (now - t0) / total);
|
|
187
|
+
setT(v);
|
|
188
|
+
if (v < 1) raf.current = requestAnimationFrame(tick);
|
|
189
|
+
else {
|
|
190
|
+
raf.current = 0;
|
|
191
|
+
setMade(true);
|
|
192
|
+
setHeld(true);
|
|
193
|
+
hold.current = window.setTimeout(() => setHeld(false), 650);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
curve.current = knots();
|
|
197
|
+
setT(0);
|
|
198
|
+
raf.current = requestAnimationFrame(tick);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/* the three windows. The haze is one long rise with no pulse: dimming and brightening before anything is decided
|
|
202
|
+
reads as hesitating */
|
|
203
|
+
const haze = smooth(0, 0.3, t);
|
|
204
|
+
const form = smooth(0.1, 0.82, t);
|
|
205
|
+
const grain = smooth(0.7, 1, t);
|
|
206
|
+
/* blur falls fast at first and then crawls through the small radii, where the detail lives */
|
|
207
|
+
const sd = mix(20, 1.6, 1 - (1 - form) ** 2.2);
|
|
208
|
+
const k = mix(2.4, 1, form);
|
|
209
|
+
const warp = (clamp(ripple, 0, 100) / 100) * 32 * (1 - smooth(0.05, 0.9, t)) ** 1.5;
|
|
210
|
+
/* a slow drift in the noise so the water moves rather than sitting as a fixed distortion */
|
|
211
|
+
const f = 0.006 + 0.0018 * Math.sin(t * 4.4);
|
|
212
|
+
const cut = (PAPER - PAPER * k).toFixed(3);
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div className="bn-gen" data-busy={busy} data-loading={loading} data-made={made}>
|
|
216
|
+
<div className="bn-gen-card" style={{ borderRadius: clamp(corner, 0, 40) }}>
|
|
217
|
+
{busy && !still && (
|
|
218
|
+
<>
|
|
219
|
+
<svg className="bn-gen-defs" aria-hidden="true">
|
|
220
|
+
<filter id={id} x="-10%" y="-10%" width="120%" height="120%" colorInterpolationFilters="sRGB">
|
|
221
|
+
<feTurbulence
|
|
222
|
+
type="fractalNoise"
|
|
223
|
+
baseFrequency={`${f.toFixed(4)} ${(f * 1.3).toFixed(4)}`}
|
|
224
|
+
numOctaves={2}
|
|
225
|
+
seed={7}
|
|
226
|
+
result="n"
|
|
227
|
+
/>
|
|
228
|
+
<feDisplacementMap
|
|
229
|
+
in="SourceGraphic"
|
|
230
|
+
in2="n"
|
|
231
|
+
scale={warp.toFixed(2)}
|
|
232
|
+
xChannelSelector="R"
|
|
233
|
+
yChannelSelector="G"
|
|
234
|
+
result="d"
|
|
235
|
+
/>
|
|
236
|
+
<feGaussianBlur in="d" stdDeviation={sd.toFixed(2)} />
|
|
237
|
+
<feComponentTransfer>
|
|
238
|
+
<feFuncR type="linear" slope={k.toFixed(3)} intercept={cut} />
|
|
239
|
+
<feFuncG type="linear" slope={k.toFixed(3)} intercept={cut} />
|
|
240
|
+
<feFuncB type="linear" slope={k.toFixed(3)} intercept={cut} />
|
|
241
|
+
</feComponentTransfer>
|
|
242
|
+
</filter>
|
|
243
|
+
</svg>
|
|
244
|
+
<Flower
|
|
245
|
+
uid={`${uid}a`}
|
|
246
|
+
style={{
|
|
247
|
+
filter: `url(#${id})`,
|
|
248
|
+
opacity: haze * mix(0.7, 1, form),
|
|
249
|
+
scale: `${mix(1.04, 1, smooth(0, 0.9, t))}`,
|
|
250
|
+
}}
|
|
251
|
+
/>
|
|
252
|
+
</>
|
|
253
|
+
)}
|
|
254
|
+
{/* the print: the whole card at rest once made, and the grain that arrives last during a run */}
|
|
255
|
+
{(made || busy) && (
|
|
256
|
+
<Flower
|
|
257
|
+
uid={`${uid}b`}
|
|
258
|
+
label="Uma flor impressa em retícula"
|
|
259
|
+
style={
|
|
260
|
+
busy
|
|
261
|
+
? {
|
|
262
|
+
opacity: still ? t : grain,
|
|
263
|
+
filter: still ? undefined : `blur(${mix(1.6, 0, grain).toFixed(2)}px)`,
|
|
264
|
+
}
|
|
265
|
+
: undefined
|
|
266
|
+
}
|
|
267
|
+
/>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
{/* the way in, in the middle of the frame: on the blank frame it is the only thing there. Once a picture is
|
|
271
|
+
made it steps aside and only comes back under the pointer, so the print is left clean */}
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
className="bn-gen-go"
|
|
275
|
+
onClick={generate}
|
|
276
|
+
disabled={loading}
|
|
277
|
+
tabIndex={loading ? -1 : 0}
|
|
278
|
+
aria-label="Gerar"
|
|
279
|
+
>
|
|
280
|
+
<span>Gerar</span>
|
|
281
|
+
</button>
|
|
282
|
+
|
|
283
|
+
{/* the count, small, in the corner: just the number going up */}
|
|
284
|
+
<span className="bn-gen-pct" role="status" aria-live="off">
|
|
285
|
+
{pct}%
|
|
286
|
+
</span>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|