@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.
- package/package.json +2 -1
- package/reference/learnings.md +12 -0
- package/studio/api/app.ts +2 -2
- package/studio/api/routes/sound.ts +22 -3
- package/studio/api/services/sound.ts +45 -0
- 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/index.html +1 -1
- 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/NewVideoPage/index.tsx +6 -2
- package/studio/web/pages/ReferencesPage/index.tsx +4 -1
- 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/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/omnichat.mjs +19 -6
- 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
|
+
"version": "0.1.5",
|
|
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",
|
package/reference/learnings.md
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
@@ -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
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
+
}
|