@crievo/omni 0.1.3 → 0.1.5

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.
Files changed (97) hide show
  1. package/package.json +2 -1
  2. package/reference/learnings.md +12 -0
  3. package/studio/api/app.ts +2 -2
  4. package/studio/api/routes/sound.ts +22 -3
  5. package/studio/api/services/sound.ts +45 -0
  6. package/studio/studio.ts +1 -0
  7. package/studio/web/components/AppShell.tsx +3 -1
  8. package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
  9. package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
  10. package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
  11. package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
  12. package/studio/web/components/Omni/OmniWidget.tsx +14 -5
  13. package/studio/web/components/Omni/PendingActions.tsx +25 -6
  14. package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
  15. package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
  16. package/studio/web/components/ResultMessage.tsx +12 -3
  17. package/studio/web/components/Sidebar.tsx +63 -24
  18. package/studio/web/components/StageStepper.tsx +105 -34
  19. package/studio/web/components/Toaster.tsx +25 -0
  20. package/studio/web/components/VoiceCarousel.tsx +19 -16
  21. package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
  22. package/studio/web/components/bencho/ActionNode.tsx +456 -0
  23. package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
  24. package/studio/web/components/bencho/BenchoScope.tsx +8 -0
  25. package/studio/web/components/bencho/Checklist.tsx +421 -0
  26. package/studio/web/components/bencho/CreateMenu.tsx +258 -0
  27. package/studio/web/components/bencho/GenerateImage.tsx +290 -0
  28. package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
  29. package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
  30. package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
  31. package/studio/web/components/bencho/NowPlaying.tsx +403 -0
  32. package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
  33. package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
  34. package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
  35. package/studio/web/components/bencho/SloshSlider.tsx +394 -0
  36. package/studio/web/components/bencho/StackedBars.tsx +342 -0
  37. package/studio/web/components/bencho/ToastStack.tsx +263 -0
  38. package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
  39. package/studio/web/components/bencho/VoiceNote.tsx +705 -0
  40. package/studio/web/components/icons.tsx +27 -0
  41. package/studio/web/components/ui/slider.tsx +34 -1
  42. package/studio/web/components/ui/switch.tsx +18 -5
  43. package/studio/web/index.html +1 -1
  44. package/studio/web/lib/omni-panel.ts +4 -1
  45. package/studio/web/lib/toast.ts +196 -0
  46. package/studio/web/lib/upload.ts +24 -0
  47. package/studio/web/pages/BenchoPage/index.tsx +74 -0
  48. package/studio/web/pages/BenchoPage/registry.ts +174 -0
  49. package/studio/web/pages/NewVideoPage/index.tsx +6 -2
  50. package/studio/web/pages/ReferencesPage/index.tsx +4 -1
  51. package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
  52. package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
  53. package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
  54. package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
  55. package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
  56. package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
  57. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
  58. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
  59. package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
  60. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
  61. package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
  62. package/studio/web/pages/VideoPage/index.tsx +4 -0
  63. package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
  64. package/studio/web/router.tsx +8 -0
  65. package/studio/web/styles/components/bencho/action-node.css +227 -0
  66. package/studio/web/styles/components/bencho/checklist.css +182 -0
  67. package/studio/web/styles/components/bencho/dock.css +188 -0
  68. package/studio/web/styles/components/bencho/gallery.css +109 -0
  69. package/studio/web/styles/components/bencho/generate.css +151 -0
  70. package/studio/web/styles/components/bencho/image-accordion.css +37 -0
  71. package/studio/web/styles/components/bencho/liq-create.css +279 -0
  72. package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
  73. package/studio/web/styles/components/bencho/one-time-code.css +148 -0
  74. package/studio/web/styles/components/bencho/picker.css +204 -0
  75. package/studio/web/styles/components/bencho/pull.css +313 -0
  76. package/studio/web/styles/components/bencho/scope.css +42 -0
  77. package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
  78. package/studio/web/styles/components/bencho/slosh.css +137 -0
  79. package/studio/web/styles/components/bencho/sound.css +260 -0
  80. package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
  81. package/studio/web/styles/components/bencho/toast-stack.css +192 -0
  82. package/studio/web/styles/components/bencho/tokens.css +243 -0
  83. package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
  84. package/studio/web/styles/components/bencho/voice-note.css +337 -0
  85. package/studio/web/styles/components/clone-voice.css +12 -2
  86. package/studio/web/styles/components/omni.css +6 -1
  87. package/studio/web/styles/components/stepper.css +123 -7
  88. package/studio/web/styles/components/toaster.css +10 -0
  89. package/studio/web/styles/components/ui/slider.css +6 -16
  90. package/studio/web/styles/components/ui/switch.css +2 -41
  91. package/studio/web/styles/components/voice-carousel.css +46 -4
  92. package/studio/web/styles/index.css +23 -0
  93. package/studio/web/styles/pages/video/layout.css +1 -4
  94. package/studio/web/styles/pages/video/sound-timeline.css +8 -0
  95. package/studio/web/styles/theme/identity/shell.css +10 -26
  96. package/tools/omnichat.mjs +19 -6
  97. package/tools/sound.mjs +67 -3
@@ -0,0 +1,374 @@
1
+ /* Small cells and a caret. Digits rise into their cells; a right code draws the cells together until they fuse into one
2
+ capsule that says so, and a wrong one sends a shiver down the row and drains the digits out.
3
+ Ported from bencho.dev (MIT).
4
+
5
+ Cells and field are one: six real inputs break paste, autofill from the notification, backspace and screen readers,
6
+ so there is one invisible input over the row and the cells are drawn underneath it.
7
+ The merge is the goo's: the cells sit under a blur-and-threshold filter, far enough apart that it never bridges them.
8
+ Verifying slides them to the middle while a capsule grows out of the same point; as they close, the filter joins
9
+ them and what is left is one shape. Nobody draws the in-between, the filter decides when two things are close enough
10
+ to be one.
11
+ `answer` is a prop because a code nobody can know is not a demonstration: Accept and Reject are the two things the
12
+ component does with a full row. */
13
+
14
+ import { useEffect, useId, useRef, useState } from "react";
15
+ import { Check } from "../icons";
16
+
17
+ /* The goo: feGaussianBlur + feColorMatrix. The matrix multiplies alpha by `cut` and subtracts half of it, so alpha
18
+ below 0.5 lands on zero and everything above 0.536 is fully opaque; that hard edge fuses nearby shapes into one blob.
19
+ It must never touch text: glyph antialiasing lives below 0.5, so type under it loses its edges. The id comes from
20
+ useId (colons stripped, not legal in a url(#...) reference) because duplicate SVG ids collide instead of scoping. */
21
+ function useGoo(blur = 7, cut = 28) {
22
+ const id = `bn-goo-${useId().replace(/:/g, "")}`;
23
+ const goo = (
24
+ <svg className="bn-liq-defs" aria-hidden="true" focusable="false">
25
+ <defs>
26
+ <filter id={id} x="-50%" y="-50%" width="200%" height="200%" colorInterpolationFilters="sRGB">
27
+ <feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="smear" />
28
+ <feColorMatrix
29
+ in="smear"
30
+ type="matrix"
31
+ values={`1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${cut} ${-(cut / 2)}`}
32
+ />
33
+ </filter>
34
+ </defs>
35
+ </svg>
36
+ );
37
+ return { id, url: `url(#${id})`, goo };
38
+ }
39
+
40
+ /* Read once: a preference, not a live input. */
41
+ const stillness = () =>
42
+ typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
43
+
44
+ /* how long a cell takes to swell and settle when a digit lands */
45
+ const POP_MS = 260;
46
+ const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
47
+ const mix = (a: number, b: number, t: number) => a + (b - a) * t;
48
+ const smooth = (a: number, b: number, v: number) => {
49
+ const u = clamp((v - a) / (b - a), 0, 1);
50
+ return u * u * (3 - 2 * u);
51
+ };
52
+
53
+ /* a cell, and the air between two */
54
+ const W = 36;
55
+ const H = 44;
56
+ const G = 7;
57
+ /* the capsule a right code becomes */
58
+ const CAP = 128;
59
+ /* The merge is a spring, not a tween: a spring a little under critical lets the capsule run past its size and come
60
+ back, stretching long and thin on the way out and settling round, which is what makes the fuse read as liquid rather
61
+ than as boxes sliding. Per frame at 60Hz, from a stiffness and a damping ratio. */
62
+ const MERGE = (() => {
63
+ const k = 0.075;
64
+ const zeta = 0.42;
65
+ return { k, d: 1 - 2 * zeta * Math.sqrt(k) };
66
+ })();
67
+ const SHAKE_MS = 620;
68
+
69
+ type Phase = "type" | "check" | "ok" | "no";
70
+
71
+ export function OneTimeCode({
72
+ /* how many digits, 4 or 6 */
73
+ length = 4,
74
+ /* what a full row gets back */
75
+ answer = "Accept",
76
+ /* the cells' corner, px; the capsule wears the same one */
77
+ corner = 12,
78
+ }: {
79
+ length?: number;
80
+ answer?: string;
81
+ corner?: number;
82
+ } = {}) {
83
+ const n = length === 4 ? 4 : 6;
84
+ const r = clamp(corner, 0, H / 2);
85
+ const rowW = n * W + (n - 1) * G;
86
+ const still = stillness();
87
+
88
+ const [code, setCode] = useState("");
89
+ const [phase, setPhase] = useState<Phase>("type");
90
+ const [focus, setFocus] = useState(false);
91
+ const input = useRef<HTMLInputElement | null>(null);
92
+
93
+ /* moving parts, read by the loop */
94
+ const caret = useRef({ x: W / 2, v: 0 });
95
+ /* when each cell last took a digit, for the little swell */
96
+ const popAt = useRef<number[]>([]);
97
+ const merge = useRef({ m: 0, v: 0, to: 0 });
98
+ const shook = useRef(0);
99
+ const timers = useRef<number[]>([]);
100
+ const raf = useRef(0);
101
+ const [, setTick] = useState(0);
102
+ const live = useRef({ code, n });
103
+ live.current = { code, n };
104
+
105
+ const later = (fn: () => void, ms: number) => {
106
+ timers.current.push(window.setTimeout(fn, ms));
107
+ };
108
+
109
+ /* The one loop: runs while something moves and stops itself after, so a row nobody is typing into costs nothing. */
110
+ const wake = () => {
111
+ if (raf.current) return;
112
+ let prev = 0;
113
+ const tick = (t: number) => {
114
+ const dt = prev ? clamp((t - prev) / 16.67, 0, 2.5) : 1;
115
+ prev = t;
116
+ let busy = false;
117
+ const { code: c, n: count } = live.current;
118
+
119
+ /* the caret, on a spring with a little give */
120
+ const target = Math.min(c.length, count - 1) * (W + G) + W / 2;
121
+ const k = caret.current;
122
+ if (still) {
123
+ k.x = target;
124
+ k.v = 0;
125
+ } else {
126
+ k.v += (target - k.x) * 0.2 * dt;
127
+ k.v *= 0.62 ** dt;
128
+ k.x += k.v * dt;
129
+ if (Math.abs(target - k.x) < 0.05 && Math.abs(k.v) < 0.05) {
130
+ k.x = target;
131
+ k.v = 0;
132
+ } else busy = true;
133
+ }
134
+
135
+ /* the merge, sprung */
136
+ const mg = merge.current;
137
+ if (mg.m !== mg.to || mg.v !== 0) {
138
+ if (still) {
139
+ mg.m = mg.to;
140
+ mg.v = 0;
141
+ } else {
142
+ mg.v += (mg.to - mg.m) * MERGE.k * dt;
143
+ mg.v *= MERGE.d ** dt;
144
+ mg.m += mg.v * dt;
145
+ if (Math.abs(mg.to - mg.m) < 0.0008 && Math.abs(mg.v) < 0.0008) {
146
+ mg.m = mg.to;
147
+ mg.v = 0;
148
+ } else busy = true;
149
+ }
150
+ }
151
+
152
+ if (t - shook.current < SHAKE_MS + 200) busy = true;
153
+
154
+ setTick(x => x + 1);
155
+ /* a cell still swelling from a digit keeps the loop alive */
156
+ if (popAt.current.some(at => t - at < POP_MS)) busy = true;
157
+ raf.current = busy ? requestAnimationFrame(tick) : 0;
158
+ };
159
+ raf.current = requestAnimationFrame(tick);
160
+ };
161
+
162
+ useEffect(
163
+ () => () => {
164
+ cancelAnimationFrame(raf.current);
165
+ raf.current = 0;
166
+ for (const id of timers.current) window.clearTimeout(id);
167
+ },
168
+ [],
169
+ );
170
+ // biome-ignore lint/correctness/useExhaustiveDependencies: wake is a closure over refs; it reruns on these three only
171
+ useEffect(() => {
172
+ wake();
173
+ }, [code, n, phase]);
174
+
175
+ const fuse = (to: 0 | 1) => {
176
+ merge.current = { ...merge.current, to };
177
+ wake();
178
+ };
179
+
180
+ /* a row that has been answered, and a length knob moving under it, both start over */
181
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only a change of length resets the row
182
+ useEffect(() => {
183
+ setCode("");
184
+ setPhase("type");
185
+ merge.current = { m: 0, v: 0, to: 0 };
186
+ }, [n]);
187
+
188
+ const onChange = (raw: string) => {
189
+ if (phase !== "type") return;
190
+ const next = raw.replace(/\D/g, "").slice(0, n);
191
+ /* each new digit swells its cell; a paste runs along the row */
192
+ if (!still && next.length > code.length) {
193
+ const t0 = performance.now();
194
+ for (let i = code.length; i < next.length; i++) popAt.current[i] = t0 + (i - code.length) * 45;
195
+ wake();
196
+ }
197
+ setCode(next);
198
+ if (next.length < n) return;
199
+ /* full: a beat of checking, then the answer */
200
+ setPhase("check");
201
+ later(
202
+ () => {
203
+ if (answer !== "Reject") {
204
+ setPhase("ok");
205
+ fuse(1);
206
+ input.current?.blur();
207
+ } else {
208
+ setPhase("no");
209
+ shook.current = performance.now();
210
+ wake();
211
+ later(
212
+ () => {
213
+ setCode("");
214
+ setPhase("type");
215
+ },
216
+ SHAKE_MS + n * 45 + 160,
217
+ );
218
+ }
219
+ },
220
+ still ? 0 : 420,
221
+ );
222
+ };
223
+
224
+ /* pressing the capsule splits it back into empty cells */
225
+ const reopen = () => {
226
+ if (phase !== "ok") return;
227
+ setCode("");
228
+ setPhase("type");
229
+ fuse(0);
230
+ input.current?.focus();
231
+ };
232
+
233
+ /* where everything is this frame */
234
+ const now = performance.now();
235
+ /* the raw spring runs past 0 and 1; the cells' travel is clamped to it, and only the capsule is allowed to take the
236
+ overshoot, as a stretch */
237
+ const raw = merge.current.m;
238
+ const m = clamp(raw, 0, 1);
239
+ const em = m;
240
+ const home = (i: number) => i * (W + G);
241
+ const mid = (rowW - W) / 2;
242
+ const since = now - shook.current;
243
+ const shaking = phase === "no" && since < SHAKE_MS && !still;
244
+ const shake = (i: number) =>
245
+ shaking ? 5 * Math.exp(-since / 190) * Math.sin((since / 1000) * Math.PI * 2 * 10 + i * 0.55) : 0;
246
+ const cellX = (i: number) => mix(home(i), mid, em) + shake(i);
247
+ /* a digit arriving: the cell that takes it swells a touch and settles, up and back on one half-sine, four per cent
248
+ at the top. An acknowledgement, not an event. */
249
+ const pop = (i: number) => {
250
+ const at = popAt.current[i];
251
+ if (at === undefined) return 1;
252
+ const u = (now - at) / POP_MS;
253
+ return u > 0 && u < 1 ? 1 + 0.04 * Math.sin(Math.PI * u) : 1;
254
+ };
255
+ /* checking: a soft wave of breath through the cells */
256
+ const breath = (i: number) => {
257
+ if (phase !== "check" || still) return pop(i);
258
+ const u = ((now % 900) / 900) * (n + 2) - i;
259
+ return 1 - 0.07 * (u > 0 && u < 2 ? Math.sin((u / 2) * Math.PI) : 0);
260
+ };
261
+ const over = raw - 1;
262
+ /* long and thin past its size, round again as it settles: width and height trade, so it reads as one soft thing */
263
+ const capW = CAP * smooth(0.3, 1, m) * (1 + Math.max(-0.12, over) * 0.9);
264
+ const capSy = 1 - Math.max(-0.12, over) * 0.55;
265
+ /* cells round off into the capsule's pill as they close in, so the two shapes agree by the time the goo joins them */
266
+ const cellR = mix(r, H / 2, smooth(0.15, 0.7, m));
267
+ const typing = phase === "type" && focus;
268
+
269
+ const { url, goo } = useGoo(3, 24);
270
+
271
+ return (
272
+ <div className="bn-otp" data-phase={phase}>
273
+ <div className="bn-otp-row" style={{ width: rowW, height: H }}>
274
+ {/* the cells and the capsule: one colour, under the filter */}
275
+ <div className="bn-otp-goo" aria-hidden="true" style={{ filter: still ? undefined : url }}>
276
+ {Array.from({ length: n }, (_, i) => (
277
+ <span
278
+ // biome-ignore lint/suspicious/noArrayIndexKey: the cells are positional and never reorder
279
+ key={i}
280
+ className="bn-otp-cell"
281
+ style={{
282
+ width: W,
283
+ borderRadius: cellR,
284
+ transform: `translateX(${cellX(i).toFixed(2)}px) scale(${breath(i).toFixed(3)})`,
285
+ }}
286
+ />
287
+ ))}
288
+ {capW > 0.5 && (
289
+ <span
290
+ className="bn-otp-cell"
291
+ /* always a full pill: the capsule is a result, not a cell, and wears the rounded shape whatever the
292
+ Corner knob says the cells are */
293
+ style={{
294
+ width: capW,
295
+ borderRadius: H / 2,
296
+ transform: `translateX(${((rowW - capW) / 2).toFixed(2)}px) scaleY(${capSy.toFixed(3)})`,
297
+ }}
298
+ />
299
+ )}
300
+ </div>
301
+
302
+ {/* the digits; an empty cell is just the cell */}
303
+ {Array.from({ length: n }, (_, i) => {
304
+ const d = code[i];
305
+ const x = cellX(i);
306
+ /* a wrong code drains right to left */
307
+ const out = phase === "no" && !still ? smooth(0, 1, (since - 300 - (n - 1 - i) * 45) / 220) : 0;
308
+ return (
309
+ <span
310
+ // biome-ignore lint/suspicious/noArrayIndexKey: the slots are positional and never reorder
311
+ key={i}
312
+ className="bn-otp-slot"
313
+ style={{
314
+ width: W,
315
+ transform: `translateX(${x.toFixed(2)}px)`,
316
+ opacity: 1 - smooth(0, 0.45, m),
317
+ }}
318
+ >
319
+ {d && (
320
+ <span
321
+ key={`${i}-${d}`}
322
+ className="bn-otp-digit"
323
+ style={{ translate: `0 ${(out * 8).toFixed(2)}px`, opacity: 1 - out }}
324
+ >
325
+ {d}
326
+ </span>
327
+ )}
328
+ </span>
329
+ );
330
+ })}
331
+
332
+ {/* the caret, stretched by its own speed */}
333
+ <i
334
+ className="bn-otp-caret"
335
+ /* not until the cells have come apart again, or it stands in an empty slot the capsule is still on */
336
+ data-on={typing && code.length < n && m < 0.08}
337
+ style={{
338
+ transform: `translateX(${(caret.current.x - 1).toFixed(2)}px) scaleX(${(1 + Math.min(5, Math.abs(caret.current.v) * 0.9)).toFixed(3)})`,
339
+ }}
340
+ />
341
+
342
+ {/* what the capsule says */}
343
+ <span
344
+ className="bn-otp-ok"
345
+ style={{ opacity: smooth(0.72, 1, m), scale: `${mix(0.92, 1, smooth(0.72, 1, m))}` }}
346
+ >
347
+ <Check size={14} weight="bold" aria-hidden="true" />
348
+ Verificado
349
+ </span>
350
+
351
+ <input
352
+ ref={input}
353
+ className="bn-otp-field"
354
+ value={code}
355
+ onChange={e => onChange(e.target.value)}
356
+ onFocus={() => setFocus(true)}
357
+ onBlur={() => setFocus(false)}
358
+ onPointerDown={e => {
359
+ if (phase === "ok") {
360
+ e.preventDefault();
361
+ reopen();
362
+ }
363
+ }}
364
+ inputMode="numeric"
365
+ autoComplete="one-time-code"
366
+ maxLength={n}
367
+ aria-label={phase === "ok" ? "Verificado. Toque para digitar outro código" : `Código de ${n} dígitos`}
368
+ spellCheck={false}
369
+ />
370
+ </div>
371
+ {goo}
372
+ </div>
373
+ );
374
+ }