@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
|
@@ -37,6 +37,8 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
|
|
|
37
37
|
const [device, setDevice] = useState("");
|
|
38
38
|
const [recording, setRecording] = useState(false);
|
|
39
39
|
const [elapsed, setElapsed] = useState(0);
|
|
40
|
+
/* the take just recorded, which the recorder shows as a clip to play back (it is also in `samples`) */
|
|
41
|
+
const [takeId, setTakeId] = useState<string | null>(null);
|
|
40
42
|
const [playing, setPlaying] = useState<string | null>(null);
|
|
41
43
|
const [warning, setWarning] = useState<string | null>(null);
|
|
42
44
|
const [name, setName] = useState(redo?.name ?? "");
|
|
@@ -61,6 +63,8 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
|
|
|
61
63
|
|
|
62
64
|
const mic = useRef<{ stream: MediaStream; ctx: AudioContext; timer: number } | null>(null);
|
|
63
65
|
const recorder = useRef<{ mr: MediaRecorder; start: number; timer: number } | null>(null);
|
|
66
|
+
/* set by cancel(): the stopped recording is dropped instead of becoming a sample */
|
|
67
|
+
const discarding = useRef(false);
|
|
64
68
|
const fileInput = useRef<HTMLInputElement>(null);
|
|
65
69
|
const player = useRef<HTMLAudioElement | null>(null);
|
|
66
70
|
const counter = useRef(0);
|
|
@@ -198,6 +202,7 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
|
|
|
198
202
|
|
|
199
203
|
function openRecorder() {
|
|
200
204
|
setView("record");
|
|
205
|
+
setTakeId(null);
|
|
201
206
|
setLanguageOk(false);
|
|
202
207
|
setWarning(null);
|
|
203
208
|
}
|
|
@@ -241,6 +246,7 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
|
|
|
241
246
|
return;
|
|
242
247
|
}
|
|
243
248
|
closeMic();
|
|
249
|
+
setTakeId(null);
|
|
244
250
|
if (languageOk) {
|
|
245
251
|
setLanguageOk(false);
|
|
246
252
|
return;
|
|
@@ -260,23 +266,26 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
|
|
|
260
266
|
const s = g ? (performance.now() - g.start) / 1000 : 0;
|
|
261
267
|
recorder.current = null;
|
|
262
268
|
setRecording(false);
|
|
269
|
+
if (discarding.current) {
|
|
270
|
+
discarding.current = false;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
263
273
|
if (s < 1.5) {
|
|
264
274
|
setWarning("A gravação ficou curta demais: leia um trecho maior do roteiro.");
|
|
265
275
|
return;
|
|
266
276
|
}
|
|
267
277
|
const blob = new Blob(parts, { type: mr.mimeType || "audio/webm" });
|
|
268
278
|
counter.current += 1;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
{
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
]);
|
|
279
|
+
const take: Sample = {
|
|
280
|
+
id: `g${Date.now()}`,
|
|
281
|
+
name: `Gravação ${counter.current}`,
|
|
282
|
+
origin: "recording",
|
|
283
|
+
blob,
|
|
284
|
+
url: URL.createObjectURL(blob),
|
|
285
|
+
s,
|
|
286
|
+
};
|
|
287
|
+
setSamples(l => [...l, take]);
|
|
288
|
+
setTakeId(take.id);
|
|
280
289
|
};
|
|
281
290
|
const startedAt = performance.now();
|
|
282
291
|
/* stops by itself at the 3 min limit, adding what was already recorded */
|
|
@@ -288,11 +297,25 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
|
|
|
288
297
|
recorder.current = { mr, start: startedAt, timer };
|
|
289
298
|
setElapsed(0);
|
|
290
299
|
setWarning(null);
|
|
300
|
+
setTakeId(null);
|
|
301
|
+
discarding.current = false;
|
|
291
302
|
setRecording(true);
|
|
292
303
|
mr.start();
|
|
293
304
|
}
|
|
294
305
|
|
|
295
|
-
|
|
306
|
+
/* a second tap, or the limit, can come before the recorder says it stopped: stopping twice throws */
|
|
307
|
+
const stop = () => {
|
|
308
|
+
const mr = recorder.current?.mr;
|
|
309
|
+
if (mr?.state === "recording") mr.stop();
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/* stops and throws the recording away: it never becomes a sample */
|
|
313
|
+
const cancel = () => {
|
|
314
|
+
const mr = recorder.current?.mr;
|
|
315
|
+
if (mr?.state !== "recording") return;
|
|
316
|
+
discarding.current = true;
|
|
317
|
+
mr.stop();
|
|
318
|
+
};
|
|
296
319
|
|
|
297
320
|
function openSave() {
|
|
298
321
|
closeMic();
|
|
@@ -338,6 +361,7 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
|
|
|
338
361
|
player.current?.pause();
|
|
339
362
|
setPlaying(null);
|
|
340
363
|
}
|
|
364
|
+
setTakeId(t => (t === id ? null : t));
|
|
341
365
|
}
|
|
342
366
|
|
|
343
367
|
function listen(m: { id: string; url: string }) {
|
|
@@ -384,6 +408,7 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
|
|
|
384
408
|
const called = recording ? starts.filter(t => t <= elapsed).length : 0;
|
|
385
409
|
const hasFish = !!(settings.voice.fish.key.masked || settings.voice.fish.key.in_env);
|
|
386
410
|
const hasCartesia = !!(settings.voice.cartesia.key.masked || settings.voice.cartesia.key.in_env);
|
|
411
|
+
const take = takeId ? (samples.find(m => m.id === takeId) ?? null) : null;
|
|
387
412
|
const total = samples.reduce((s, m) => s + m.s, 0);
|
|
388
413
|
totalRef.current = total;
|
|
389
414
|
const measured = total + (recording ? elapsed : 0);
|
|
@@ -502,6 +527,8 @@ export function useCloneVoice({ settings, onCreate, onClose, redo, onRedone }: C
|
|
|
502
527
|
back,
|
|
503
528
|
start,
|
|
504
529
|
stop,
|
|
530
|
+
cancel,
|
|
531
|
+
take,
|
|
505
532
|
openSave,
|
|
506
533
|
onPickFiles,
|
|
507
534
|
remove,
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import type { MissingDms } from "../../hooks/useMissingDms";
|
|
5
5
|
import { SEND_MODES } from "../../lib/missing-dms";
|
|
6
|
+
import { BenchoScope } from "../bencho/BenchoScope";
|
|
7
|
+
import { SlideToConfirm } from "../bencho/SlideToConfirm";
|
|
6
8
|
import { Check, Power, X } from "../icons";
|
|
7
9
|
|
|
8
10
|
export function BatchSend({
|
|
@@ -116,10 +118,17 @@ export function BatchSend({
|
|
|
116
118
|
)}
|
|
117
119
|
</p>
|
|
118
120
|
<div className="actions">
|
|
119
|
-
<
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
<BenchoScope>
|
|
122
|
+
<SlideToConfirm
|
|
123
|
+
className="bn-sld-grow"
|
|
124
|
+
width="fill"
|
|
125
|
+
label={commentOnly ? "Deslize para responder" : "Deslize para mandar"}
|
|
126
|
+
busyLabel="Mandando…"
|
|
127
|
+
busy={busy === "send"}
|
|
128
|
+
disabled={!!busy}
|
|
129
|
+
onConfirm={send}
|
|
130
|
+
/>
|
|
131
|
+
</BenchoScope>
|
|
123
132
|
<button className="button" disabled={!!busy} onClick={() => setConfirm(false)}>
|
|
124
133
|
<X className="ic" /> Cancelar
|
|
125
134
|
</button>
|
|
@@ -244,9 +244,17 @@ export function OmniWidget({ menuCollapsed }: { menuCollapsed: boolean }) {
|
|
|
244
244
|
const [autoCall, setAutoCall] = useState(false);
|
|
245
245
|
const toggle = () => {
|
|
246
246
|
if (open) return collapse();
|
|
247
|
-
|
|
248
|
-
if (live
|
|
249
|
-
if (!info
|
|
247
|
+
/* a call already on, minimized in the card: the second click brings the dock */
|
|
248
|
+
if (live) return setOpen(true);
|
|
249
|
+
if (!info) return setOpen(true);
|
|
250
|
+
/* with no call to start (Omni off, unavailable, voice quota over) the dock opens and the greeting plays */
|
|
251
|
+
if (!info.config.enabled || unavailable || claudeMode) {
|
|
252
|
+
setOpen(true);
|
|
253
|
+
return greeting.play(info.config.gender);
|
|
254
|
+
}
|
|
255
|
+
/* the first click only activates the voice: the call starts minimized, living in the card (with the
|
|
256
|
+
menu collapsed there is no card to show it, so the dock opens) */
|
|
257
|
+
if (menuCollapsed) setOpen(true);
|
|
250
258
|
setAutoCall(true);
|
|
251
259
|
void conv.start(mode, undefined, { greet: info.config.gender, holdIdle: true });
|
|
252
260
|
};
|
|
@@ -331,7 +339,7 @@ export function OmniWidget({ menuCollapsed }: { menuCollapsed: boolean }) {
|
|
|
331
339
|
}, [autoCall, conv.status, userSpoke, holdIdle, discard]);
|
|
332
340
|
/* "Oi, Omni" (the switch in the settings): starts the conversation the way opening the panel does (a
|
|
333
341
|
silent call ends by itself); in Claude mode it starts the recording. With the menu expanded the call
|
|
334
|
-
starts minimized, living in the sidebar card (
|
|
342
|
+
starts minimized, living in the sidebar card (a click opens the dock); with the menu
|
|
335
343
|
collapsed there is no card to show it, so the dock opens. Claude mode always opens it: the
|
|
336
344
|
recording's button is there. It sleeps while Omni talks. */
|
|
337
345
|
const callByVoice = () => {
|
|
@@ -418,10 +426,11 @@ export function OmniWidget({ menuCollapsed }: { menuCollapsed: boolean }) {
|
|
|
418
426
|
available,
|
|
419
427
|
face: info ? face : null,
|
|
420
428
|
open,
|
|
429
|
+
active: live,
|
|
421
430
|
ring: bubble,
|
|
422
431
|
glow,
|
|
423
432
|
});
|
|
424
|
-
}, [available, info, face, open, bubble, glow]);
|
|
433
|
+
}, [available, info, face, open, live, bubble, glow]);
|
|
425
434
|
/* while the agent works without speaking, the name gives way to a shimmering "pensando…" */
|
|
426
435
|
const omniName = bubble === "thinking" ? <b className="omni-name-thinking">Pensando…</b> : <b>Omni</b>;
|
|
427
436
|
/* agent.enabled false: Omni is off, no button and no panel (a live conversation still ends normally) */
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
/* An action Omni staged: nothing runs until the user
|
|
2
|
-
then
|
|
1
|
+
/* An action Omni staged: nothing runs until the user slides to confirm here, or says yes to Omni, who
|
|
2
|
+
then confirms it on the server (confirm_action; the server only takes a card the user has spoken after, and the
|
|
3
|
+
card leaves this list when it resolves, so the voice path never needs the slide). */
|
|
3
4
|
|
|
4
5
|
import { useEffect, useRef, useState } from "react";
|
|
5
6
|
import { CaretLeft, CaretRight } from "../icons";
|
|
7
|
+
import { BenchoScope } from "../bencho/BenchoScope";
|
|
8
|
+
import { SlideToConfirm } from "../bencho/SlideToConfirm";
|
|
6
9
|
import type { AgentPendingAction } from "../../types/api";
|
|
7
10
|
|
|
8
11
|
const KIND_TITLE: Record<AgentPendingAction["kind"], string> = {
|
|
@@ -34,6 +37,14 @@ const KIND_BUSY: Record<AgentPendingAction["kind"], string> = {
|
|
|
34
37
|
omnichat_send: "Mandando…",
|
|
35
38
|
};
|
|
36
39
|
|
|
40
|
+
/* the slide's words: the verb says what leaves the studio, since there is no second button to read it from */
|
|
41
|
+
const KIND_SLIDE: Partial<Record<AgentPendingAction["kind"], string>> = {
|
|
42
|
+
publish: "Deslize para publicar",
|
|
43
|
+
schedule: "Deslize para agendar",
|
|
44
|
+
omnichat: "Deslize para gravar",
|
|
45
|
+
omnichat_send: "Deslize para mandar",
|
|
46
|
+
};
|
|
47
|
+
|
|
37
48
|
/* a summary past three lines folds, with a "Mostrar tudo" that opens it */
|
|
38
49
|
function Summary({ text }: { text: string }) {
|
|
39
50
|
const ref = useRef<HTMLParagraphElement>(null);
|
|
@@ -83,6 +94,7 @@ export function PendingActions({
|
|
|
83
94
|
const r = await onResolve(a, confirm);
|
|
84
95
|
setBusy(null);
|
|
85
96
|
setErrors(e => ({ ...e, [a.id]: r.ok ? "" : (r.error ?? "falhou") }));
|
|
97
|
+
return r.ok;
|
|
86
98
|
};
|
|
87
99
|
/* mouse drag scrolls the carousel (touch scrolls natively); a drag never presses a button */
|
|
88
100
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
@@ -150,12 +162,19 @@ export function PendingActions({
|
|
|
150
162
|
{errors[a.id] && <p className="response error">{errors[a.id]}</p>}
|
|
151
163
|
</div>
|
|
152
164
|
<div className="omni-pending-actions">
|
|
153
|
-
<
|
|
165
|
+
<BenchoScope>
|
|
166
|
+
<SlideToConfirm
|
|
167
|
+
width="fill"
|
|
168
|
+
label={KIND_SLIDE[a.kind] ?? "Deslize para confirmar"}
|
|
169
|
+
busyLabel={KIND_BUSY[a.kind]}
|
|
170
|
+
busy={busy === a.id}
|
|
171
|
+
disabled={busy === a.id}
|
|
172
|
+
onConfirm={() => act(a, true)}
|
|
173
|
+
/>
|
|
174
|
+
</BenchoScope>
|
|
175
|
+
<button type="button" className="button small" disabled={busy === a.id} onClick={() => act(a, false)}>
|
|
154
176
|
Cancelar
|
|
155
177
|
</button>
|
|
156
|
-
<button type="button" className="button primary" disabled={busy === a.id} onClick={() => act(a, true)}>
|
|
157
|
-
{busy === a.id ? KIND_BUSY[a.kind] : "Confirmar"}
|
|
158
|
-
</button>
|
|
159
178
|
</div>
|
|
160
179
|
</div>
|
|
161
180
|
))}
|
|
@@ -7,6 +7,8 @@ import { useState } from "react";
|
|
|
7
7
|
import type { RuleEditor } from "../../hooks/useRuleEditor";
|
|
8
8
|
import type { OmnichatRuleDetail } from "../../types/api";
|
|
9
9
|
import { ArrowCounterClockwise, ArrowLeft, ArrowRight, Check, FloppyDisk, Play, SlidersHorizontal, X } from "../icons";
|
|
10
|
+
import { BenchoScope } from "../bencho/BenchoScope";
|
|
11
|
+
import { SlideToConfirm } from "../bencho/SlideToConfirm";
|
|
10
12
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../ui/accordion";
|
|
11
13
|
import { STEPS, stepInfo, type StepId } from "./rule-steps";
|
|
12
14
|
import { MessageStep } from "./steps/MessageStep";
|
|
@@ -111,9 +113,17 @@ export function RuleConfig({
|
|
|
111
113
|
)}
|
|
112
114
|
{confirmSave && enabled ? (
|
|
113
115
|
<>
|
|
114
|
-
<
|
|
115
|
-
<
|
|
116
|
-
|
|
116
|
+
<BenchoScope>
|
|
117
|
+
<SlideToConfirm
|
|
118
|
+
className="bn-sld-grow"
|
|
119
|
+
width="fill"
|
|
120
|
+
label="Deslize para gravar com a regra ligada"
|
|
121
|
+
busyLabel="Gravando…"
|
|
122
|
+
busy={busy === "save"}
|
|
123
|
+
disabled={!!busy}
|
|
124
|
+
onConfirm={save}
|
|
125
|
+
/>
|
|
126
|
+
</BenchoScope>
|
|
117
127
|
<button className="button" disabled={!!busy} onClick={() => setConfirmSave(false)}>
|
|
118
128
|
<X className="ic" /> Cancelar
|
|
119
129
|
</button>
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import { capitalize } from "../../lib/format";
|
|
4
4
|
import type { RuleEditor } from "../../hooks/useRuleEditor";
|
|
5
5
|
import type { OmnichatRuleDetail } from "../../types/api";
|
|
6
|
-
import {
|
|
6
|
+
import { BenchoScope } from "../bencho/BenchoScope";
|
|
7
|
+
import { SlideToConfirm } from "../bencho/SlideToConfirm";
|
|
8
|
+
import { X } from "../icons";
|
|
7
9
|
import { triggerLabel } from "../RuleForm";
|
|
8
10
|
|
|
9
11
|
export function RuleEnableConfirm({
|
|
@@ -30,9 +32,17 @@ export function RuleEnableConfirm({
|
|
|
30
32
|
: ` ${capitalize(thePost)} ainda não subiu: começa quando ele for publicado.`}
|
|
31
33
|
</p>
|
|
32
34
|
<div className="actions">
|
|
33
|
-
<
|
|
34
|
-
<
|
|
35
|
-
|
|
35
|
+
<BenchoScope>
|
|
36
|
+
<SlideToConfirm
|
|
37
|
+
className="bn-sld-grow"
|
|
38
|
+
width="fill"
|
|
39
|
+
label="Deslize para ligar e responder"
|
|
40
|
+
busyLabel="Ligando…"
|
|
41
|
+
busy={busy === "enable"}
|
|
42
|
+
disabled={!!busy}
|
|
43
|
+
onConfirm={() => enable(true)}
|
|
44
|
+
/>
|
|
45
|
+
</BenchoScope>
|
|
36
46
|
<button className="button" disabled={!!busy} onClick={() => setConfirm(false)}>
|
|
37
47
|
<X className="ic" /> Cancelar
|
|
38
48
|
</button>
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
1
2
|
import type { ActionResult } from "../lib/api";
|
|
3
|
+
import { isToastable, toastResult } from "../lib/toast";
|
|
2
4
|
|
|
3
|
-
/* the output (ok) or the error of the last save
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
/* the output (ok) or the error of the last save: a toast, which goes away on its own. A long or multi-line text
|
|
6
|
+
(command output, a stack of failures) is still written here, since the reader may need to come back to it;
|
|
7
|
+
`inline` forces that for a result that must stay on the screen. */
|
|
8
|
+
export function ResultMessage({ result, inline }: { result: ActionResult; inline?: boolean }) {
|
|
9
|
+
const toasted = !!result && !inline && isToastable(result);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (toasted) toastResult(result);
|
|
12
|
+
}, [toasted, result]);
|
|
13
|
+
return result && !toasted ? (
|
|
6
14
|
<p className={`response ${result.ok ? "ok" : "error"}`}>{result.ok ? result.output : result.error}</p>
|
|
7
15
|
) : null;
|
|
16
|
+
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
import { Link, useMatchRoute, useNavigate } from "@tanstack/react-router";
|
|
11
11
|
import { useAdmin } from "../hooks/useAdmin";
|
|
12
|
+
import { BenchoScope } from "./bencho/BenchoScope";
|
|
13
|
+
import { CreateMenu, type CreateMenuItem } from "./bencho/CreateMenu";
|
|
12
14
|
import { useRunner } from "../hooks/useRunner";
|
|
13
15
|
import { omniLevel, toggleOmniPanel, useOmniPanel } from "../lib/omni-panel";
|
|
14
16
|
import { OmniGlow } from "./Omni/OmniGlow";
|
|
@@ -22,9 +24,10 @@ import {
|
|
|
22
24
|
Dashboard,
|
|
23
25
|
Gear,
|
|
24
26
|
ArrowExpand,
|
|
27
|
+
GridFour,
|
|
25
28
|
Minus,
|
|
26
29
|
Plug,
|
|
27
|
-
|
|
30
|
+
Play,
|
|
28
31
|
Power,
|
|
29
32
|
ShieldCheck,
|
|
30
33
|
SidebarLeft,
|
|
@@ -152,6 +155,42 @@ function NavLink({
|
|
|
152
155
|
);
|
|
153
156
|
}
|
|
154
157
|
|
|
158
|
+
declare module "@tanstack/history" {
|
|
159
|
+
interface HistoryState {
|
|
160
|
+
/* /new: which deliverables come checked */
|
|
161
|
+
deliverables?: ("video" | "carousel")[];
|
|
162
|
+
/* /styles: the new-style form comes open */
|
|
163
|
+
creating?: boolean;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* The Criar menu: what the studio can start. The video and the carousel are the same form (/new, the carousel with its
|
|
168
|
+
deliverable already checked); a new visual style opens the form at the top of Estilos visuais. The destination reads
|
|
169
|
+
the router's history state, so no route needs a search schema. */
|
|
170
|
+
function useCreateItems(): CreateMenuItem[] {
|
|
171
|
+
const navigate = useNavigate();
|
|
172
|
+
return [
|
|
173
|
+
{
|
|
174
|
+
id: "video",
|
|
175
|
+
label: "Vídeo",
|
|
176
|
+
icon: Play,
|
|
177
|
+
onSelect: () => navigate({ to: "/new", state: { deliverables: ["video"] } }),
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: "carousel",
|
|
181
|
+
label: "Carrossel",
|
|
182
|
+
icon: GridFour,
|
|
183
|
+
onSelect: () => navigate({ to: "/new", state: { deliverables: ["carousel"] } }),
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "style",
|
|
187
|
+
label: "Estilo visual",
|
|
188
|
+
icon: Sparkle,
|
|
189
|
+
onSelect: () => navigate({ to: "/styles", state: { creating: true } }),
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
|
|
155
194
|
/* `missing`: the required setup steps still missing (Primeiros passos shows while there are any) */
|
|
156
195
|
export function Sidebar({
|
|
157
196
|
collapsed,
|
|
@@ -165,7 +204,7 @@ export function Sidebar({
|
|
|
165
204
|
showGettingStarted: boolean;
|
|
166
205
|
}) {
|
|
167
206
|
const isActive = useIsActive();
|
|
168
|
-
const
|
|
207
|
+
const createItems = useCreateItems();
|
|
169
208
|
const { hosted } = useRunner();
|
|
170
209
|
const { isAdmin } = useAdmin();
|
|
171
210
|
const omni = useOmniPanel();
|
|
@@ -194,24 +233,19 @@ export function Sidebar({
|
|
|
194
233
|
<span className="menu-group-name">{g.name}</span>
|
|
195
234
|
{g.items.map(item =>
|
|
196
235
|
item.id === "videos" ? (
|
|
197
|
-
/* "Criar
|
|
198
|
-
(a button inside <a> is not valid HTML), and its
|
|
236
|
+
/* "Criar" lives inside the Conteúdos row: the pill sits over the link, as a sibling
|
|
237
|
+
(a button inside <a> is not valid HTML), and its menu hangs from its corner */
|
|
199
238
|
<div key={item.id} className="menu-item-with-action">
|
|
200
239
|
<NavLink item={item} collapsed={collapsed} active={isActive(item)} />
|
|
201
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}}
|
|
211
|
-
>
|
|
212
|
-
<Plus weight="bold" aria-hidden="true" />
|
|
213
|
-
<span>Criar</span>
|
|
214
|
-
</button>
|
|
240
|
+
<BenchoScope>
|
|
241
|
+
<CreateMenu
|
|
242
|
+
className="menu-create"
|
|
243
|
+
label="Criar"
|
|
244
|
+
items={createItems}
|
|
245
|
+
anchor="top-end"
|
|
246
|
+
pillHeight={28}
|
|
247
|
+
/>
|
|
248
|
+
</BenchoScope>
|
|
215
249
|
</div>
|
|
216
250
|
) : (
|
|
217
251
|
<NavLink key={item.id} item={item} collapsed={collapsed} active={isActive(item)} />
|
|
@@ -249,18 +283,23 @@ export function Sidebar({
|
|
|
249
283
|
className={`menu-omni ${omni.ring}${omni.open ? " open" : ""}`}
|
|
250
284
|
onClick={toggleOmniPanel}
|
|
251
285
|
aria-expanded={omni.open}
|
|
252
|
-
aria-label={omni.open ? "Minimizar o Omni" : "Falar com Omni"}
|
|
253
|
-
title={
|
|
286
|
+
aria-label={omni.open ? "Minimizar o Omni" : omni.active ? "Abrir o Omni" : "Falar com Omni"}
|
|
287
|
+
title={
|
|
288
|
+
omni.open ? "Minimizar o Omni" : omni.active ? "Abrir o Omni" : collapsed ? "Falar com Omni" : undefined
|
|
289
|
+
}
|
|
254
290
|
>
|
|
255
291
|
<VoiceAvatar seed={omni.face ?? OMNI_AVATAR} size={40} />
|
|
256
292
|
{/* minimized in a call, the button says what Omni is doing */}
|
|
257
293
|
<span className="menu-label">
|
|
258
294
|
{omni.open ? "Falar com Omni" : (GLOW_LABEL[omni.glow] ?? "Falar com Omni")}
|
|
259
295
|
</span>
|
|
260
|
-
{/* the
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
296
|
+
{/* the first click activates the voice (minimized in the card); only then the icon shows, and the
|
|
297
|
+
next click opens the dock (with it open, the icon says it minimizes) */}
|
|
298
|
+
{(omni.active || omni.open) && (
|
|
299
|
+
<span className="menu-omni-expand" aria-hidden="true">
|
|
300
|
+
{omni.open ? <Minus /> : <ArrowExpand />}
|
|
301
|
+
</span>
|
|
302
|
+
)}
|
|
264
303
|
</button>
|
|
265
304
|
</OmniGlow>
|
|
266
305
|
)}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
/* The pipeline stepper: each stage with its state now. On the overview each
|
|
2
|
-
stage on the video page; on the page, it picks the stage.
|
|
1
|
+
/* The pipeline stepper: each stage as a Bencho action node card with its state now. On the overview each card links
|
|
2
|
+
to the stage on the video page; on the page, it picks the stage. The corner buttons are the actions the studio
|
|
3
|
+
already has for the stage (approve, build the export again, open its process); a stage with none shows none. */
|
|
3
4
|
|
|
5
|
+
import { type CSSProperties, useState } from "react";
|
|
4
6
|
import { Link } from "@tanstack/react-router";
|
|
5
7
|
import { useNow } from "../hooks/useNow";
|
|
8
|
+
import { soundRoutes } from "../hooks/useSound";
|
|
9
|
+
import { api, enc, send, type ActionResult } from "../lib/api";
|
|
6
10
|
import {
|
|
7
11
|
STAGES,
|
|
8
12
|
getStageState,
|
|
@@ -12,7 +16,10 @@ import {
|
|
|
12
16
|
type StageState,
|
|
13
17
|
} from "../lib/stages";
|
|
14
18
|
import type { Queue, Ticket } from "../types/api";
|
|
15
|
-
import {
|
|
19
|
+
import { ActionNode, type NodeAction } from "./bencho/ActionNode";
|
|
20
|
+
import { BenchoScope } from "./bencho/BenchoScope";
|
|
21
|
+
import { ResultMessage } from "./ResultMessage";
|
|
22
|
+
import { ArrowClockwise, Check, SidebarRight, X } from "./icons";
|
|
16
23
|
|
|
17
24
|
function stepMark(s: StageState, stage: StageDef) {
|
|
18
25
|
switch (s.kind) {
|
|
@@ -33,13 +40,15 @@ function stepMark(s: StageState, stage: StageDef) {
|
|
|
33
40
|
}
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
/* `stages`: the pipeline shown (the video's by default; the carousel's in PIPELINES)
|
|
43
|
+
/* `stages`: the pipeline shown (the video's by default; the carousel's in PIPELINES).
|
|
44
|
+
`onProcess`: opens the stage's process (the page's side panel); without it that action is not offered. */
|
|
37
45
|
export function StageStepper({
|
|
38
46
|
video,
|
|
39
47
|
queue,
|
|
40
48
|
runs,
|
|
41
49
|
selected,
|
|
42
50
|
onSelect,
|
|
51
|
+
onProcess,
|
|
43
52
|
compact = false,
|
|
44
53
|
stages = STAGES,
|
|
45
54
|
}: {
|
|
@@ -49,39 +58,101 @@ export function StageStepper({
|
|
|
49
58
|
runs?: Ticket[];
|
|
50
59
|
selected?: string;
|
|
51
60
|
onSelect?: (id: string) => void;
|
|
61
|
+
onProcess?: (id: string) => void;
|
|
52
62
|
compact?: boolean;
|
|
53
63
|
}) {
|
|
54
64
|
const now = useNow();
|
|
65
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
66
|
+
const [note, setNote] = useState<ActionResult>(null);
|
|
67
|
+
const param = { slug: enc(video.slug) };
|
|
68
|
+
const run = async (key: string, request: () => Promise<NonNullable<ActionResult>>) => {
|
|
69
|
+
setBusy(key);
|
|
70
|
+
setNote(null);
|
|
71
|
+
const r = await request();
|
|
72
|
+
setBusy(null);
|
|
73
|
+
setNote(r);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/* the buttons a stage's card carries: only what the studio already does, in the order of how often it is wanted */
|
|
77
|
+
const actionsOf = (stage: StageDef, s: StageState): NodeAction[] => {
|
|
78
|
+
const list: NodeAction[] = [];
|
|
79
|
+
if (s.kind === "user" && stage.id === "script")
|
|
80
|
+
list.push({
|
|
81
|
+
id: "approve",
|
|
82
|
+
label: "Aprovar o roteiro",
|
|
83
|
+
icon: Check,
|
|
84
|
+
disabled: !!busy,
|
|
85
|
+
onClick: () =>
|
|
86
|
+
run("approve", () => send(api.videos[":slug"].script.decide.$post({ param, json: { decision: "approve" } }))),
|
|
87
|
+
});
|
|
88
|
+
if (s.kind === "user" && stage.id === "motion_script")
|
|
89
|
+
list.push({
|
|
90
|
+
id: "approve",
|
|
91
|
+
label: "Aprovar todas as cenas",
|
|
92
|
+
icon: Check,
|
|
93
|
+
disabled: !!busy,
|
|
94
|
+
onClick: () => run("approve", () => send(api.videos[":slug"].scenes["approve-all"].$post({ param, json: {} }))),
|
|
95
|
+
});
|
|
96
|
+
/* the editor's "Renderizar" is the same call; it is offered here only when the export is out of date */
|
|
97
|
+
if (s.kind === "warning" && stage.id === "render" && video.status?.stages.find(x => x.id === "export")?.outdated)
|
|
98
|
+
list.push({
|
|
99
|
+
id: "reassemble",
|
|
100
|
+
label: "Montar o export de novo",
|
|
101
|
+
icon: ArrowClockwise,
|
|
102
|
+
disabled: !!busy,
|
|
103
|
+
onClick: () => run("reassemble", () => soundRoutes(video.slug).reassemble()),
|
|
104
|
+
});
|
|
105
|
+
if (onProcess && ["running", "queued", "redoing", "error", "warning"].includes(s.kind))
|
|
106
|
+
list.push({
|
|
107
|
+
id: "process",
|
|
108
|
+
label: "Abrir o processamento",
|
|
109
|
+
icon: SidebarRight,
|
|
110
|
+
onClick: () => onProcess(stage.id),
|
|
111
|
+
});
|
|
112
|
+
return list;
|
|
113
|
+
};
|
|
114
|
+
|
|
55
115
|
return (
|
|
56
|
-
|
|
57
|
-
{stages.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
116
|
+
<>
|
|
117
|
+
<ol className={`stepper cards${compact ? " compact" : ""}`} style={{ "--steps": stages.length } as CSSProperties}>
|
|
118
|
+
{stages.map(stage => {
|
|
119
|
+
const s = getStageState(stage, video, queue, runs);
|
|
120
|
+
const hint = `${stage.num} · ${stage.title}${s.kind === "warning" ? `: ${s.reason}` : ""}`;
|
|
121
|
+
return (
|
|
122
|
+
<li key={stage.id} className={`step ${s.kind}${selected === stage.id ? " chosen" : ""}`}>
|
|
123
|
+
<BenchoScope>
|
|
124
|
+
<ActionNode
|
|
125
|
+
title={stage.name}
|
|
126
|
+
description={stageStateDetail(s, now) || "Ainda não feita"}
|
|
127
|
+
hint={hint}
|
|
128
|
+
mark={<span className="step-mark">{stepMark(s, stage)}</span>}
|
|
129
|
+
actions={actionsOf(stage, s)}
|
|
130
|
+
people={[]}
|
|
131
|
+
selected={selected === stage.id}
|
|
132
|
+
width="100%"
|
|
133
|
+
dense
|
|
134
|
+
corner={compact ? 12 : 14}
|
|
135
|
+
air={0}
|
|
136
|
+
fan="down"
|
|
137
|
+
render={props =>
|
|
138
|
+
onSelect ? (
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
{...props}
|
|
142
|
+
onClick={() => onSelect(stage.id)}
|
|
143
|
+
aria-current={selected === stage.id ? "step" : undefined}
|
|
144
|
+
/>
|
|
145
|
+
) : (
|
|
146
|
+
<Link to="/videos/$slug/$stage" params={{ slug: video.slug, stage: stage.id }} {...props} />
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
/>
|
|
150
|
+
</BenchoScope>
|
|
151
|
+
</li>
|
|
152
|
+
);
|
|
153
|
+
})}
|
|
154
|
+
</ol>
|
|
155
|
+
<ResultMessage result={note} />
|
|
156
|
+
</>
|
|
86
157
|
);
|
|
87
158
|
}
|