@crievo/omni 0.1.3 → 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.
Files changed (96) 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/sound.mjs +67 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crievo/omni",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "OmniVideo no seu computador: o CLI omni, o studio local e as ferramentas do pipeline.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "border-beam": "1.4.1",
31
31
  "hono": "4.13.9",
32
32
  "marked": "^18.1.0",
33
+ "motion": "^14.3.0",
33
34
  "react": "19",
34
35
  "react-day-picker": "^10.0.2",
35
36
  "react-dom": "19",
@@ -790,3 +790,15 @@ these voices).
790
790
  - **Symptom:** the cloud answered `429 error code: 1027` (the Workers Free plan's 100k requests a day), and the studio said "A cloud do omnichat não responde". The Worker had 140k requests that day, ~5k an hour since the evening before and 43k in one hour, with only a few real comments.
791
791
  - **Cause:** a read that wrote. `pullRecord` (`tools/omnichat.mjs`) rewrote `omnichat-record.json` on every read, with a fresh `pulled_at`. The write is a studio notice (`events.ts`), the notice makes every open omnichat page reread every rule (`log --json`, one cloud request each), and that read wrote again: an endless loop, ~400–600 requests a minute per open Omnichat tab. The hosted studio's offline copy reread the same routes on every notice even with no tab open (~80 a minute). `publish.mjs` (the scheduler, every minute while a cloud job runs) and `metrics.mjs` (the account file) also rewrote unchanged files.
792
792
  - **Fixed:** a pull, `writePub` and the metrics account file write only when the content changed; the studio caches the cloud health check for 60 s and each rule's log for 30 s while its record file is unchanged; the offline copy is gone. Rule for the future: anything a read path writes into a watched file must skip an unchanged write, or it loops against whatever it calls.
793
+
794
+ ## Magenta tint on semi-transparent UI under the motion-blur SVG filter (18/10/2026)
795
+
796
+ - **Symptom:** in `2026-10-18-guia-de-motion-design-spotify` (c02, mid-pan), list rows fading in (opacity < 1, with their own CSS `blur()` from the depth of field) came out purple/magenta while the camera moved; rows at opacity 1 were fine.
797
+ - **Cause:** the world layer carries `filter: url(#mb)` (an SVG `feGaussianBlur` for vertical motion blur). SVG filters default to `color-interpolation-filters: linearRGB`, and Chrome's conversion of the semi-transparent, already-blurred children through that path shifts dark colours toward magenta.
798
+ - **Fixed:** `color-interpolation-filters="sRGB"` on the `<filter>`. Any SVG blur used as motion blur over UI should carry it.
799
+
800
+ ## Frame 0 of a scene missed an element gated on its own beat (18/10/2026)
801
+
802
+ - **Symptom:** in `2026-10-18-roteiro-pronto-guia-de-motion`, c06's first frame was empty cream: the symbol that should be born at scale 0.35 on frame 0 only showed from the second frame, and the c05 → c06 cut sheet read as "ring gone, blank, symbol pops".
803
+ - **Cause:** floating point. The build sums scene durations, so `OMNIVIDEO.start` for c06 was `16.799999999999997`, while the beat `simbolo` (from `video.scenes[].beats`) was `16.8`. A helper gated with `if (T >= t0)` (`OV.signAt`) was false at `t = 0`. Progress helpers (`seg`, clamped) don't care; only hard `>=` gates do.
804
+ - **Fixed:** the gate takes a tolerance, `T >= t0 - 1e-4`. Rule: any on/off gate on a beat time in a continuous composition compares with an epsilon, never with a bare `>=`.
package/studio/api/app.ts CHANGED
@@ -33,7 +33,7 @@ import { schedule } from "./routes/schedule";
33
33
  import { services } from "./routes/services";
34
34
  import { sessions } from "./routes/sessions";
35
35
  import { settings } from "./routes/settings";
36
- import { sound } from "./routes/sound";
36
+ import { sound, SOUND_UPLOAD_PATH } from "./routes/sound";
37
37
  import { thumbnail } from "./routes/thumbnail";
38
38
  import { usage } from "./routes/usage";
39
39
  import { videos } from "./routes/videos";
@@ -75,7 +75,7 @@ export function createApp({ port }: AppOptions) {
75
75
  .route("/", agentHistory)
76
76
  .route("/", events);
77
77
  const app = router()
78
- .use(localOnly({ port, uploadPaths: [UPLOAD_PATH] }))
78
+ .use(localOnly({ port, uploadPaths: [UPLOAD_PATH, SOUND_UPLOAD_PATH] }))
79
79
  .use(jsonCharset)
80
80
  .route("/api", api)
81
81
  .route("/", media);
@@ -1,17 +1,36 @@
1
- /* Sound timeline (SoundTimeline): data, the draft plan (sound.mjs), save to scenes.json, reassemble
2
- and adding an Epidemic effect. */
1
+ /* Sound timeline (SoundTimeline): data, the draft plan (sound.mjs), save to scenes.json, reassemble,
2
+ adding an Epidemic effect and the user's own track (upload). */
3
3
 
4
4
  import { json } from "../lib/validate";
5
5
  import { router } from "../lib/hono";
6
6
  import { result, shaped } from "../lib/result";
7
7
  import { videoDir } from "../lib/video";
8
- import { captureFromPage, downloadEffect, getSoundData, planDraft, reassemble, saveSound } from "../services/sound";
8
+ import { noTimeout } from "../middleware/no-timeout";
9
+ import {
10
+ captureFromPage,
11
+ downloadEffect,
12
+ getSoundData,
13
+ planDraft,
14
+ reassemble,
15
+ saveSound,
16
+ uploadTrack,
17
+ } from "../services/sound";
9
18
  import { refreshStatus } from "../services/videos";
10
19
  import { emptyBody } from "../../shared/schemas/common";
11
20
  import { soundCaptureBody, soundDownloadBody, soundDraftBody } from "../../shared/schemas/sound";
12
21
  import type { SoundData } from "../../shared/types/api";
13
22
 
23
+ /* the track upload takes the raw body with the file name in x-file-name and the video in x-video (the
24
+ local-only guard checks the name, like the style upload) */
25
+ export const SOUND_UPLOAD_PATH = "/api/sound/upload";
26
+
14
27
  export const sound = router()
28
+ .post("/sound/upload", noTimeout, async c => {
29
+ const dir = videoDir(decodeURIComponent(c.req.header("x-video") ?? ""));
30
+ /* NaN when the header is missing: the tool finds an empty file by itself */
31
+ const size = Number(c.req.header("content-length") ?? Number.NaN);
32
+ return result(c, await uploadTrack(dir, c.req.header("x-file-name") ?? "", c.req.raw.body, size));
33
+ })
15
34
  .get("/videos/:slug/sound", c => {
16
35
  const dir = videoDir(c.req.param("slug"));
17
36
  try {
@@ -5,6 +5,7 @@
5
5
  mixes. Saving writes to scenes.json; the export only changes on reassembly. */
6
6
 
7
7
  import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
8
9
  import { join, resolve, sep, basename } from "node:path";
9
10
  import { ROOT, NODE, TOOLS_DIR, readJson } from "./root";
10
11
  import { loadVideo, beatTime } from "../../../tools/timing.mjs";
@@ -267,6 +268,50 @@ export async function downloadEffect(dir: string, body: any): Promise<Result> {
267
268
  return { ok: true, output: o.trim() };
268
269
  }
269
270
 
271
+ /* A track the user sends from the editor (the upload): the raw body goes to a temp file and
272
+ `sound.mjs import` turns it into an MP3 in the video's sfx/, with the origin "upload". The answer's
273
+ output starts with the file name, like the Epidemic download's. */
274
+ const AUDIO_FILE = /\.(mp3|wav|m4a|aac|flac|ogg|oga|opus|aif|aiff|wma|mp4)$/i;
275
+ const MAX_UPLOAD = 200 * 1024 * 1024;
276
+ export async function uploadTrack(
277
+ dir: string,
278
+ rawName: string,
279
+ body: ReadableStream | null,
280
+ size: number,
281
+ ): Promise<Result> {
282
+ let original = rawName;
283
+ try {
284
+ original = decodeURIComponent(rawName);
285
+ } catch {
286
+ /* keeps the raw name */
287
+ }
288
+ const file = basename(original);
289
+ if (!AUDIO_FILE.test(file))
290
+ return { ok: false, error: "o arquivo precisa ser um áudio (mp3, wav, m4a, aac, flac, ogg, aiff)" };
291
+ if (!body || size === 0) return { ok: false, error: "arquivo vazio" };
292
+ if (size > MAX_UPLOAD) return { ok: false, error: "o arquivo passa de 200 MB" };
293
+ const tmp = join(tmpdir(), `omni-upload-${Date.now()}${file.slice(file.lastIndexOf("."))}`);
294
+ try {
295
+ await Bun.write(tmp, new Response(body));
296
+ /* the file's own name (not the temp file's) is the track's name and title */
297
+ const title = file.replace(/\.[^.]+$/, "");
298
+ const p = Bun.spawn([NODE, join(TOOLS_DIR, "sound.mjs"), "import", dir, tmp, title, "--title", title], {
299
+ cwd: ROOT,
300
+ stdout: "pipe",
301
+ stderr: "pipe",
302
+ env: { ...process.env, OMNIVIDEO_ORIGIN: "studio" },
303
+ });
304
+ const [o, e, exit] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
305
+ if (exit !== 0)
306
+ return { ok: false, error: (e || o).trim().replace(/^erro:\s*/, "") || `sound.mjs saiu com ${exit}` };
307
+ return { ok: true, output: o.trim() };
308
+ } catch (err: any) {
309
+ return { ok: false, error: `não deu para guardar o upload: ${err?.message ?? err}` };
310
+ } finally {
311
+ rmSync(tmp, { force: true });
312
+ }
313
+ }
314
+
270
315
  /* adds a sound from an Epidemic page (playlist, theme, search): the editor's agent session opens it in
271
316
  Chrome, plays the tracks, takes the lqmp3 URL from the player and runs sound.mjs download. The
272
317
  timeline reloads when the agent stops. */
package/studio/studio.ts CHANGED
@@ -81,6 +81,7 @@ try {
81
81
  "/settings/:category": index,
82
82
  "/new": index,
83
83
  "/getting-started": index,
84
+ "/bencho": index,
84
85
  "/styles": index,
85
86
  "/styles/:name": index,
86
87
  /* any other path opens the app, which shows the 404 page inside the menu; the API and
@@ -1,5 +1,5 @@
1
1
  /* The root route component: the top banners, the menu, the page (<Outlet/>) inside the scrolling
2
- .content, the lightbox, the approval notice, Omni and, over everything, the screen for a studio that lost its connection, the voice agent (its panel opens from the sidebar button). */
2
+ .content, the lightbox, the approval notice, Omni, the toasts and, over everything, the screen for a studio that lost its connection, the voice agent (its panel opens from the sidebar button). */
3
3
 
4
4
  import { useEffect, useRef } from "react";
5
5
  import { Outlet, useLocation, useMatchRoute } from "@tanstack/react-router";
@@ -10,6 +10,7 @@ import { TopBanners } from "./TopBanners";
10
10
  import { Lightbox } from "./Lightbox";
11
11
  import { OmniWidget } from "./Omni/OmniWidget";
12
12
  import { StudioOffline } from "./StudioOffline";
13
+ import { Toaster } from "./Toaster";
13
14
  import { Sidebar } from "./Sidebar";
14
15
 
15
16
  /* the page a location belongs to: switching the stage of a video (/videos/x/<stage>) or the
@@ -59,6 +60,7 @@ export function AppShell() {
59
60
  <ApprovalNotice />
60
61
  <OmniWidget menuCollapsed={collapsed} />
61
62
  <StudioOffline />
63
+ <Toaster />
62
64
  </>
63
65
  );
64
66
  }
@@ -41,8 +41,11 @@ export function RecordStep({ c }: { c: CloneVoiceState }) {
41
41
  total={c.total}
42
42
  level={c.level}
43
43
  creating={creating}
44
+ take={c.take}
44
45
  onStart={c.start}
45
46
  onStop={c.stop}
47
+ onCancel={c.cancel}
48
+ onDiscardTake={c.remove}
46
49
  />
47
50
  )}
48
51
  </>
@@ -1,8 +1,12 @@
1
- import { clock, MAX_S } from "../../lib/clone-voice";
2
- import { Circle, Microphone, Stop } from "../icons";
1
+ import { MAX_S } from "../../lib/clone-voice";
2
+ import { VoiceNote } from "../bencho/VoiceNote";
3
+ import { BenchoScope } from "../bencho/BenchoScope";
4
+ import { Microphone } from "../icons";
3
5
  import { Picker } from "../ui/picker";
6
+ import type { Sample } from "./types";
4
7
 
5
- /* recording step 2: the microphone choice, the permission or the timer, the record button and the level */
8
+ /* recording step 2: the microphone choice, then the permission button or the voice note pill (tap to record, tap to
9
+ stop, the take played back right there) and, at rest, the level */
6
10
  export function RecorderBar({
7
11
  device,
8
12
  devices,
@@ -14,8 +18,11 @@ export function RecorderBar({
14
18
  total,
15
19
  level,
16
20
  creating,
21
+ take,
17
22
  onStart,
18
23
  onStop,
24
+ onCancel,
25
+ onDiscardTake,
19
26
  }: {
20
27
  device: string;
21
28
  devices: MediaDeviceInfo[];
@@ -27,9 +34,14 @@ export function RecorderBar({
27
34
  total: number;
28
35
  level: number;
29
36
  creating: boolean;
37
+ /* the take just recorded: the pill shows it as a clip */
38
+ take: Sample | null;
30
39
  onStart: () => void;
31
40
  onStop: () => void;
41
+ onCancel: () => void;
42
+ onDiscardTake: (id: string) => void;
32
43
  }) {
44
+ const full = total >= MAX_S;
33
45
  return (
34
46
  <footer className="clone-bar">
35
47
  <Picker
@@ -47,32 +59,37 @@ export function RecorderBar({
47
59
  <button type="button" className="button pill" onClick={onAllowMicrophone}>
48
60
  <Microphone className="ic" /> Permitir microfone
49
61
  </button>
50
- ) : recording ? (
51
- <span className="clone-time mono">
52
- <Circle weight="fill" className="ic" /> {clock(elapsed)}
53
- </span>
54
62
  ) : (
55
- <span className="subtle">Pronto para gravar</span>
63
+ <div className="clone-voicenote">
64
+ <BenchoScope>
65
+ <VoiceNote
66
+ mode="tap"
67
+ state={recording ? "recording" : take ? "clip" : "idle"}
68
+ level={level}
69
+ elapsed={elapsed}
70
+ maxSeconds={MAX_S - total}
71
+ audioUrl={take?.url}
72
+ duration={take?.s}
73
+ disabled={creating || (full && !take)}
74
+ onStart={onStart}
75
+ onStop={onStop}
76
+ onCancel={onCancel}
77
+ onDiscard={() => take && onDiscardTake(take.id)}
78
+ onAgain={take && !full && !creating ? onStart : undefined}
79
+ />
80
+ </BenchoScope>
81
+ </div>
56
82
  )}
57
- <button
58
- type="button"
59
- className={`clone-record${recording ? " active" : ""}`}
60
- disabled={microphone !== "ok" || creating || (!recording && total >= MAX_S)}
61
- aria-label={recording ? "Parar a gravação" : "Começar a gravar"}
62
- onClick={recording ? onStop : onStart}
63
- >
64
- {recording ? <Stop weight="fill" /> : <Microphone weight="fill" />}
65
- </button>
66
83
  <span className="clone-level" aria-live="polite">
67
- {microphone === "ok" && level > 0.04 ? (
84
+ {microphone === "ok" && !recording && level > 0.04 ? (
68
85
  <span className="bars" aria-hidden="true">
69
86
  {[0.5, 0.8, 1, 0.8, 0.5].map((k, i) => (
70
87
  <i key={i} style={{ height: `${Math.max(12, Math.min(100, level * 100 * k))}%` }} />
71
88
  ))}
72
89
  </span>
73
- ) : (
90
+ ) : microphone === "ok" && !recording ? (
74
91
  "Sem ruído detectado"
75
- )}
92
+ ) : null}
76
93
  </span>
77
94
  </footer>
78
95
  );
@@ -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
- setSamples(l => [
270
- ...l,
271
- {
272
- id: `g${Date.now()}`,
273
- name: `Gravação ${counter.current}`,
274
- origin: "recording",
275
- blob,
276
- url: URL.createObjectURL(blob),
277
- s,
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
- const stop = () => recorder.current?.mr.stop();
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
- <button className="button approve" disabled={!!busy} onClick={send}>
120
- <Power className="ic" />{" "}
121
- {busy === "send" ? "Mandando…" : `Confirmar: ${commentOnly ? "responder" : "mandar"} ${onBehalf}`}
122
- </button>
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
- setOpen(true);
248
- if (live || !info) return;
249
- if (!info.config.enabled || unavailable || claudeMode) return greeting.play(info.config.gender);
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 (its icon or a click opens the dock); with the menu
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 presses Confirmar here, or says yes to Omni, who
2
- then presses it (confirm_action; the server only takes a card the user has spoken after). */
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
- <button type="button" className="button" disabled={busy === a.id} onClick={() => act(a, false)}>
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
- <button className="button approve" disabled={!!busy} onClick={save}>
115
- <Check className="ic" /> {busy === "save" ? "Gravando…" : "Confirmar: gravar com a regra ligada"}
116
- </button>
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 { Power, X } from "../icons";
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
- <button className="button approve" disabled={!!busy} onClick={() => enable(true)}>
34
- <Power className="ic" /> {busy === "enable" ? "Ligando…" : `Confirmar: responder ${onBehalf}`}
35
- </button>
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
- export const ResultMessage = ({ result }: { result: ActionResult }) =>
5
- result ? (
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
+ }