@crievo/omni 0.1.7 → 0.1.9
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/omnivideo.config.example.json +2 -0
- package/package.json +1 -1
- package/reference/cloud.md +15 -1
- package/reference/learnings.md +7 -0
- package/reference/scenes-contract.md +3 -2
- package/reference/studio.md +3 -2
- package/studio/api/app.ts +4 -0
- package/studio/api/routes/cost.ts +13 -0
- package/studio/api/routes/me.ts +8 -0
- package/studio/api/services/agent-persona.ts +1 -0
- package/studio/api/services/agent-tools.ts +2 -2
- package/studio/api/services/cost.ts +22 -0
- package/studio/api/services/me.ts +67 -0
- package/studio/api/services/sessions.ts +8 -2
- package/studio/api/services/sound.ts +17 -6
- package/studio/runner/runner.ts +57 -10
- package/studio/shared/omni-screens.ts +1 -1
- package/studio/shared/schemas/cost.ts +10 -0
- package/studio/shared/types/api.ts +77 -1
- package/studio/studio.ts +27 -2
- package/studio/web/components/AppShell.tsx +11 -3
- package/studio/web/components/FirstConnection.tsx +222 -0
- package/studio/web/components/OfflineNotice.tsx +59 -0
- package/studio/web/components/SetupSteps.tsx +63 -0
- package/studio/web/components/Sidebar.tsx +10 -25
- package/studio/web/components/StudioOffline.tsx +48 -50
- package/studio/web/components/UserCard.tsx +79 -0
- package/studio/web/components/icons.tsx +2 -0
- package/studio/web/hooks/useComputers.ts +55 -0
- package/studio/web/hooks/useCost.ts +11 -0
- package/studio/web/hooks/useMe.ts +32 -0
- package/studio/web/hooks/useSetupProgress.ts +54 -0
- package/studio/web/lib/me.ts +17 -0
- package/studio/web/lib/query-client.ts +6 -0
- package/studio/web/lib/settings-categories.ts +2 -2
- package/studio/web/lib/setup-retry.ts +29 -0
- package/studio/web/lib/sound-timeline.ts +33 -0
- package/studio/web/lib/studio-offline.ts +105 -59
- package/studio/web/pages/ConnectPreviewPage/index.tsx +215 -0
- package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +18 -9
- package/studio/web/pages/SettingsPage/components/AgentCostPanel.tsx +253 -0
- package/studio/web/pages/SettingsPage/components/ComputersPanel.tsx +145 -0
- package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +2 -2
- package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +3 -3
- package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +7 -2
- package/studio/web/pages/SettingsPage/index.tsx +15 -1
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +5 -2
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +10 -8
- package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +31 -18
- package/studio/web/router.tsx +8 -0
- package/studio/web/styles/components/setup-steps.css +163 -0
- package/studio/web/styles/components/user-card.css +121 -0
- package/studio/web/styles/index.css +2 -0
- package/studio/web/styles/pages/settings/shell.css +79 -0
- package/studio/web/styles/pages/settings/usage.css +47 -0
- package/studio/web/styles/pages/studio-offline.css +224 -38
- package/tools/cost.mjs +114 -0
- package/tools/lib/agents.mjs +3 -2
- package/tools/lib/channel.mjs +1 -1
- package/tools/lib/cloud-user.mjs +58 -0
- package/tools/lib/cost.mjs +357 -0
- package/tools/lib/deps.mjs +27 -12
- package/tools/lib/oauth-client.mjs +28 -14
- package/tools/lib/pages-style.mjs +67 -0
- package/tools/lib/pairing.mjs +1 -1
- package/tools/lib/schedule/schedule.mjs +1 -1
- package/tools/lib/self-update.mjs +122 -0
- package/tools/lib/setup-progress.mjs +315 -0
- package/tools/lib/up.mjs +176 -61
- package/tools/metrics.mjs +6 -1
- package/tools/omni.mjs +41 -16
- package/tools/omnichat.mjs +45 -9
- package/tools/platforms.mjs +75 -14
- package/tools/runner.mjs +6 -6
- package/tools/setup.mjs +18 -5
- package/tools/sound.mjs +198 -94
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/* The menu's user card, under Omni's: the avatar, the name and the email of who is using the studio (hosted: the
|
|
2
|
+
Google account that signed in; local: the Omni login or the Claude Code account, hooks/useMe.ts). The Google
|
|
3
|
+
picture when there is one (or when it fails to load), else two initials. An icon button on the right signs out:
|
|
4
|
+
hosted, the Worker's POST /exit (the computers and the actions that went through are in Configurações → Agente
|
|
5
|
+
de IA); at 127.0.0.1, with the Omni account shown, `omni logout` for this computer, after a confirmation (a
|
|
6
|
+
Claude Code account has nothing to sign out of here). Collapsed menu: just the avatar, and the button takes its place while the pointer is on it,
|
|
7
|
+
as the sidebar toggle does over the logo. */
|
|
8
|
+
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
import { useMe } from "../hooks/useMe";
|
|
11
|
+
import { useRunner } from "../hooks/useRunner";
|
|
12
|
+
import { api, errorText, send } from "../lib/api";
|
|
13
|
+
import { displayNameOf, initialsOf } from "../lib/me";
|
|
14
|
+
import { queryClient, queryKeys } from "../lib/query-client";
|
|
15
|
+
import { toast, toastResult } from "../lib/toast";
|
|
16
|
+
import { SignOut } from "./icons";
|
|
17
|
+
|
|
18
|
+
const CONFIRM = "Sair da conta Omni neste computador? O plugin pede o login de novo na próxima sessão do Claude Code.";
|
|
19
|
+
|
|
20
|
+
/* the local studio's sign-out: asks first, then ends this computer's Omni login and rereads who is using the studio */
|
|
21
|
+
async function signOutHere() {
|
|
22
|
+
if (!confirm(CONFIRM)) return;
|
|
23
|
+
try {
|
|
24
|
+
toastResult(await send(api.me.logout.$post()), undefined, { always: true });
|
|
25
|
+
} catch (e) {
|
|
26
|
+
toast.error("Algo deu errado", { body: errorText(e) });
|
|
27
|
+
}
|
|
28
|
+
await queryClient.invalidateQueries({ queryKey: queryKeys.me });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function UserCard({ collapsed }: { collapsed: boolean }) {
|
|
32
|
+
const me = useMe();
|
|
33
|
+
const { hosted } = useRunner();
|
|
34
|
+
const [broken, setBroken] = useState(false);
|
|
35
|
+
if (!me) return null;
|
|
36
|
+
const name = displayNameOf(me);
|
|
37
|
+
return (
|
|
38
|
+
<div className="menu-user" title={collapsed ? `${name} · ${me.email}` : undefined}>
|
|
39
|
+
{me.image && !broken ? (
|
|
40
|
+
<img
|
|
41
|
+
className="menu-user-photo"
|
|
42
|
+
src={me.image}
|
|
43
|
+
alt=""
|
|
44
|
+
width={36}
|
|
45
|
+
height={36}
|
|
46
|
+
referrerPolicy="no-referrer"
|
|
47
|
+
onError={() => setBroken(true)}
|
|
48
|
+
/>
|
|
49
|
+
) : (
|
|
50
|
+
<span className="menu-user-initials" aria-hidden="true">
|
|
51
|
+
{initialsOf(me)}
|
|
52
|
+
</span>
|
|
53
|
+
)}
|
|
54
|
+
<span className="menu-user-text menu-label">
|
|
55
|
+
<b>{name}</b>
|
|
56
|
+
<span>{me.email}</span>
|
|
57
|
+
</span>
|
|
58
|
+
{hosted ? (
|
|
59
|
+
<form method="post" action="/exit" className="menu-user-exit">
|
|
60
|
+
<button type="submit" className="menu-user-signout" title="Sair" aria-label="Sair">
|
|
61
|
+
<SignOut aria-hidden="true" />
|
|
62
|
+
</button>
|
|
63
|
+
</form>
|
|
64
|
+
) : (
|
|
65
|
+
me.source === "omni" && (
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
className="menu-user-signout"
|
|
69
|
+
title="Sair"
|
|
70
|
+
aria-label="Sair"
|
|
71
|
+
onClick={() => void signOutHere()}
|
|
72
|
+
>
|
|
73
|
+
<SignOut aria-hidden="true" />
|
|
74
|
+
</button>
|
|
75
|
+
)
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
FolderOpenIcon,
|
|
26
26
|
ImageDone01Icon,
|
|
27
27
|
Link01Icon,
|
|
28
|
+
Logout01Icon,
|
|
28
29
|
Plug01Icon,
|
|
29
30
|
PowerIcon,
|
|
30
31
|
SentIcon,
|
|
@@ -208,6 +209,7 @@ export const PlayCircle = icon(PlayCircleIcon, "PlayCircle");
|
|
|
208
209
|
export const Plug = icon(Plug01Icon, "Plug");
|
|
209
210
|
export const Plus = icon(Add01Icon, "Plus");
|
|
210
211
|
export const Power = icon(PowerIcon, "Power");
|
|
212
|
+
export const SignOut = icon(Logout01Icon, "SignOut");
|
|
211
213
|
export const Prohibit = icon(BlockedIcon, "Prohibit");
|
|
212
214
|
export const Queue = icon(LeftToRightListBulletIcon, "Queue");
|
|
213
215
|
export const Shuffle = icon(ShuffleIcon, "Shuffle");
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { invalidate, queryKeys } from "../lib/query-client";
|
|
3
|
+
import type { AuditEntry, Computer } from "../types/api";
|
|
4
|
+
|
|
5
|
+
/* Configurações → Agente de IA, in the hosted studio: the computers signed in to the account and the actions
|
|
6
|
+
that went through to them. The Worker answers both itself (cloud/studio/src/account-routes.mjs), so they
|
|
7
|
+
work even while the studio isn't connected. Not there at 127.0.0.1: `enabled` is the hosted flag. */
|
|
8
|
+
|
|
9
|
+
async function getJson<T>(path: string): Promise<T> {
|
|
10
|
+
const r = await fetch(path, { headers: { accept: "application/json" } });
|
|
11
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
12
|
+
return (await r.json()) as T;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useComputers(enabled: boolean) {
|
|
16
|
+
const q = useQuery({
|
|
17
|
+
queryKey: queryKeys.computers,
|
|
18
|
+
queryFn: () => getJson<{ email: string; computers: Computer[] }>("/session/computers"),
|
|
19
|
+
enabled,
|
|
20
|
+
/* "conectado agora" moves: look again while the screen is open */
|
|
21
|
+
refetchInterval: enabled ? 10_000 : false,
|
|
22
|
+
});
|
|
23
|
+
const revoke = useMutation({
|
|
24
|
+
mutationFn: async (id: string) => {
|
|
25
|
+
const r = await fetch("/session/computers/revoke", {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "content-type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ id }),
|
|
29
|
+
});
|
|
30
|
+
if (!r.ok)
|
|
31
|
+
throw new Error(((await r.json().catch(() => null)) as { error?: string } | null)?.error ?? `${r.status}`);
|
|
32
|
+
},
|
|
33
|
+
onSuccess: () => invalidate(queryKeys.computers),
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
email: q.data?.email ?? null,
|
|
37
|
+
computers: q.data?.computers ?? null,
|
|
38
|
+
error: q.error ? "Não consegui carregar os computadores." : null,
|
|
39
|
+
revoke,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useAudit(enabled: boolean) {
|
|
44
|
+
const q = useQuery({
|
|
45
|
+
queryKey: queryKeys.audit,
|
|
46
|
+
queryFn: () => getJson<{ entries: AuditEntry[] }>("/session/audit"),
|
|
47
|
+
enabled,
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
entries: q.data?.entries ?? null,
|
|
51
|
+
error: q.error ? "Não consegui carregar as ações." : null,
|
|
52
|
+
reload: () => void q.refetch(),
|
|
53
|
+
loading: q.isFetching,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { api } from "../lib/api";
|
|
2
|
+
import { queryKeys } from "../lib/query-client";
|
|
3
|
+
import { useLiveQuery } from "./useLiveQuery";
|
|
4
|
+
|
|
5
|
+
/* GET /api/cost: what the agents spent in the last `days` days (null = everything) */
|
|
6
|
+
export function useCost(days: number | null) {
|
|
7
|
+
const { data, error, reload } = useLiveQuery(queryKeys.cost(days), () =>
|
|
8
|
+
api.cost.$get({ query: days ? { days: String(days) } : {} }),
|
|
9
|
+
);
|
|
10
|
+
return { cost: data, error, reload };
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { queryKeys } from "../lib/query-client";
|
|
3
|
+
import type { Me } from "../types/api";
|
|
4
|
+
import { useRunner } from "./useRunner";
|
|
5
|
+
|
|
6
|
+
/* Who is using the studio, for the menu's user card. Hosted: the Worker's session (/session/me), which answers
|
|
7
|
+
even while the computer is away. At 127.0.0.1: the studio's own /api/me (the Omni login of this computer, else
|
|
8
|
+
the Claude Code account). Nobody known, or the lookup failing, is no card: the menu is never blocked on it. */
|
|
9
|
+
async function read(hosted: boolean): Promise<Me | null> {
|
|
10
|
+
if (hosted) {
|
|
11
|
+
const r = await fetch("/session/me", { headers: { accept: "application/json" } });
|
|
12
|
+
if (!r.ok) return null;
|
|
13
|
+
const m = (await r.json()) as { email?: string; name?: string | null; image?: string | null };
|
|
14
|
+
return m.email ? { email: m.email, name: m.name ?? null, image: m.image ?? null, source: "session" } : null;
|
|
15
|
+
}
|
|
16
|
+
const r = await fetch("/api/me", { headers: { accept: "application/json" } });
|
|
17
|
+
if (!r.ok) return null;
|
|
18
|
+
return ((await r.json()) as { me: Me | null }).me;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useMe() {
|
|
22
|
+
const { hosted, online } = useRunner();
|
|
23
|
+
/* wait for the first answer about where this page is: asking the wrong side first would just fail */
|
|
24
|
+
const known = online !== null;
|
|
25
|
+
const q = useQuery({
|
|
26
|
+
queryKey: [...queryKeys.me, hosted],
|
|
27
|
+
queryFn: () => read(hosted),
|
|
28
|
+
enabled: known,
|
|
29
|
+
staleTime: 5 * 60_000,
|
|
30
|
+
});
|
|
31
|
+
return q.data ?? null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { invalidate, queryKeys } from "../lib/query-client";
|
|
3
|
+
import { isListening, pendingStep } from "../lib/setup-retry";
|
|
4
|
+
import type { SetupRecord, SetupRetryRequest } from "../types/api";
|
|
5
|
+
|
|
6
|
+
/* While the studio isn't connected, the CLI reports the first-time setup to the user's cloud (the page can't ask
|
|
7
|
+
the computer): `setup` in the cloud config. Asked every 3 s, only while a screen needs it. The page asks for a
|
|
8
|
+
retry the same way, the other direction: `setup_retry`, which the process that ran the setup reads. */
|
|
9
|
+
const POLL_MS = 3_000;
|
|
10
|
+
|
|
11
|
+
type Config = { setup?: SetupRecord; setup_retry?: SetupRetryRequest };
|
|
12
|
+
|
|
13
|
+
async function readConfig(): Promise<{ setup: SetupRecord | null; retry: SetupRetryRequest | null }> {
|
|
14
|
+
const r = await fetch("/api/cloud/config", { headers: { accept: "application/json" } });
|
|
15
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
16
|
+
const c = ((await r.json()) as { config?: Config }).config ?? {};
|
|
17
|
+
const s = c.setup;
|
|
18
|
+
const q = c.setup_retry;
|
|
19
|
+
return {
|
|
20
|
+
setup: s && Array.isArray(s.steps) ? s : null,
|
|
21
|
+
retry: q && typeof q.id === "string" ? q : null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useSetupProgress(enabled: boolean) {
|
|
26
|
+
const q = useQuery({
|
|
27
|
+
queryKey: queryKeys.setupProgress,
|
|
28
|
+
queryFn: readConfig,
|
|
29
|
+
enabled,
|
|
30
|
+
refetchInterval: enabled ? POLL_MS : false,
|
|
31
|
+
});
|
|
32
|
+
return { setup: q.data?.setup ?? null, retryRequest: q.data?.retry ?? null, loaded: q.isFetched || q.isError };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "Tentar de novo": writes the request the computer is waiting for, and says whether anyone is there to hear it */
|
|
36
|
+
export function useSetupRetry(setup: SetupRecord | null, request: SetupRetryRequest | null) {
|
|
37
|
+
const ask = useMutation({
|
|
38
|
+
mutationFn: async (step: string) => {
|
|
39
|
+
const r = await fetch("/api/cloud/config", {
|
|
40
|
+
method: "PUT",
|
|
41
|
+
headers: { "content-type": "application/json" },
|
|
42
|
+
body: JSON.stringify({ setup_retry: { id: crypto.randomUUID(), step, at: Date.now() } }),
|
|
43
|
+
});
|
|
44
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
45
|
+
},
|
|
46
|
+
onSuccess: () => invalidate(queryKeys.setupProgress),
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
listening: isListening(setup),
|
|
50
|
+
pending: pendingStep(setup, request) ?? (ask.isPending ? ask.variables : null),
|
|
51
|
+
onRetry: (step: string) => ask.mutate(step),
|
|
52
|
+
failed: ask.isError,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* A name to show for who is using the studio: the account's own, else what comes before the @ of the email. */
|
|
2
|
+
export const displayNameOf = (me: { name: string | null; email: string }) =>
|
|
3
|
+
me.name?.trim() || me.email.split("@")[0] || me.email;
|
|
4
|
+
|
|
5
|
+
/* Two letters for an avatar without a picture: the first letter of the first and of the last word of the name
|
|
6
|
+
("Pedro Felipe" → "PF"); one word gives its first two letters ("Pedro" → "PE"); no name, the start of the email. */
|
|
7
|
+
export function initialsOf(me: { name: string | null; email: string }) {
|
|
8
|
+
const words = (me.name ?? "").trim().split(/\s+/).filter(Boolean);
|
|
9
|
+
const letters = (w: string) =>
|
|
10
|
+
[...w.normalize("NFD").replace(/[\u0300-\u036f]/g, "")].filter(c => /\p{L}|\p{N}/u.test(c));
|
|
11
|
+
const pair =
|
|
12
|
+
words.length >= 2
|
|
13
|
+
? [letters(words[0])[0], letters(words[words.length - 1])[0]]
|
|
14
|
+
: letters(words[0] ?? me.email.split("@")[0] ?? "").slice(0, 2);
|
|
15
|
+
const out = pair.filter(Boolean).join("").toUpperCase();
|
|
16
|
+
return out || "?";
|
|
17
|
+
}
|
|
@@ -36,6 +36,7 @@ export const queryKeys = {
|
|
|
36
36
|
reel: (slug: string) => ["reel", slug] as const,
|
|
37
37
|
settings: ["settings"] as const,
|
|
38
38
|
usage: ["usage"] as const,
|
|
39
|
+
cost: (days: number | null) => ["cost", days ?? "all"] as const,
|
|
39
40
|
accounts: ["accounts"] as const,
|
|
40
41
|
schedule: ["schedule"] as const,
|
|
41
42
|
onboarding: ["onboarding"] as const,
|
|
@@ -43,6 +44,11 @@ export const queryKeys = {
|
|
|
43
44
|
admin: ["admin"] as const,
|
|
44
45
|
agentConversations: ["agent-conversations"] as const,
|
|
45
46
|
runner: ["runner"] as const,
|
|
47
|
+
me: ["me"] as const,
|
|
48
|
+
/* the setup the CLI reports to the user's cloud, and the Worker's lists of computers and actions */
|
|
49
|
+
setupProgress: ["setup-progress"] as const,
|
|
50
|
+
computers: ["computers"] as const,
|
|
51
|
+
audit: ["audit"] as const,
|
|
46
52
|
};
|
|
47
53
|
|
|
48
54
|
/* the omnichat rule API of a video, of a content's carousel or of an outside post */
|
|
@@ -15,9 +15,9 @@ export const CATEGORY_GROUPS: { name: string; categories: SettingsCategory[] }[]
|
|
|
15
15
|
},
|
|
16
16
|
{
|
|
17
17
|
id: "usage",
|
|
18
|
-
name: "Uso
|
|
18
|
+
name: "Uso e custo",
|
|
19
19
|
description:
|
|
20
|
-
"O que
|
|
20
|
+
"O que os agentes gastam por vídeo e etapa, e o que sobra na Fish e na Cartesia. O plano e a cota das vozes ficam em Voz → Provedores de voz",
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
id: "agent",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* "Tentar de novo" on a setup step: what the page knows from the report the CLI writes (tools/lib/setup-progress.mjs).
|
|
2
|
+
After a failure the process that ran the setup stays alive and looks at the cloud config every few seconds, stamping
|
|
3
|
+
`watching_at`; a request the page writes is answered by stamping `handled`. So the button shows only while
|
|
4
|
+
someone is there to hear it, and a request asked but not yet answered shows as "waiting for the computer". */
|
|
5
|
+
|
|
6
|
+
import type { SetupRecord, SetupRetryRequest } from "../types/api";
|
|
7
|
+
|
|
8
|
+
/* the process looks every 5 s and stamps every 15: a stamp this old means nobody is there any more */
|
|
9
|
+
export const LISTENING_MS = 45_000;
|
|
10
|
+
/* a request nobody has answered after this long, with nothing running, is lost */
|
|
11
|
+
export const PENDING_MS = 90_000;
|
|
12
|
+
|
|
13
|
+
export const isListening = (setup: Pick<SetupRecord, "watching_at"> | null, now = Date.now()) =>
|
|
14
|
+
!!setup?.watching_at && now - setup.watching_at < LISTENING_MS;
|
|
15
|
+
|
|
16
|
+
/** the step asked for again and not answered yet ("all" included), or null */
|
|
17
|
+
export function pendingStep(
|
|
18
|
+
setup: Pick<SetupRecord, "handled" | "steps"> | null,
|
|
19
|
+
request: SetupRetryRequest | null,
|
|
20
|
+
now = Date.now(),
|
|
21
|
+
): string | null {
|
|
22
|
+
if (!setup || !request || request.id === setup.handled) return null;
|
|
23
|
+
const working = setup.steps.some(s => s.state === "running");
|
|
24
|
+
return working || now - request.at < PENDING_MS ? request.step : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** the steps that show as waiting for the computer: the one asked for, or every failed one for "all" */
|
|
28
|
+
export const isWaiting = (stepId: string, state: string, pending: string | null) =>
|
|
29
|
+
!!pending && state === "error" && (pending === "all" || pending === stepId);
|
|
@@ -34,6 +34,39 @@ export const effectSrc = (slug: string, e: SoundEffectInfo | undefined) =>
|
|
|
34
34
|
!e ? null : e.origin === "kit" ? `/kit/${encodeURIComponent(e.name)}` : mediaSrc(slug, `sfx/${e.name}`);
|
|
35
35
|
/* the kit's "bed" role shows as background music */
|
|
36
36
|
export const roleLabel = (p: string) => (p === "bed" ? "música de fundo" : p);
|
|
37
|
+
/* a kit's effect is named "kit/name.mp3" (its ref); the lists show the file's name */
|
|
38
|
+
export const fileName = (ref: string) => ref.slice(ref.indexOf("/") + 1);
|
|
39
|
+
/* the kits the catalog's effects come from, the default one first */
|
|
40
|
+
export const kitsOf = (catalog: SoundCatalog) => {
|
|
41
|
+
const names = [...new Set(catalog.kit.map(e => e.kit ?? ""))];
|
|
42
|
+
return names.sort((a, b) => (a === catalog.defaultKit ? -1 : b === catalog.defaultKit ? 1 : a.localeCompare(b)));
|
|
43
|
+
};
|
|
44
|
+
/* the library's tab of this video's own sounds (kit names never start with an underscore) */
|
|
45
|
+
export const VIDEO_TAB = "__video";
|
|
46
|
+
/* the library's tabs and the open one: each kit (the default first, open at start), then this video's
|
|
47
|
+
sounds. A kit's sounds come grouped by role; the video's in one group with no heading. `picked` is
|
|
48
|
+
the tab the user chose, if it still exists; `filter` is the search, in lower case. */
|
|
49
|
+
export function kitLibrary(catalog: SoundCatalog, picked: string | null, filter: string) {
|
|
50
|
+
const f = filter.trim().toLowerCase();
|
|
51
|
+
const passes = (e: SoundEffectInfo) =>
|
|
52
|
+
!f || `${e.name} ${e.title} ${e.role ?? ""} ${e.music ? "música de fundo cama" : ""}`.toLowerCase().includes(f);
|
|
53
|
+
const kits = kitsOf(catalog);
|
|
54
|
+
const tabs = [
|
|
55
|
+
...kits.map(k => ({ id: k, name: k, n: catalog.kit.filter(e => (e.kit ?? "") === k).length })),
|
|
56
|
+
{ id: VIDEO_TAB, name: "deste vídeo", n: catalog.video.length },
|
|
57
|
+
];
|
|
58
|
+
const fallback =
|
|
59
|
+
catalog.defaultKit && kits.includes(catalog.defaultKit) ? catalog.defaultKit : (kits[0] ?? VIDEO_TAB);
|
|
60
|
+
const tab = picked && tabs.some(t => t.id === picked) ? picked : fallback;
|
|
61
|
+
const groups: [string, SoundEffectInfo[]][] =
|
|
62
|
+
tab === VIDEO_TAB
|
|
63
|
+
? [["", catalog.video.filter(passes)]]
|
|
64
|
+
: Object.keys(catalog.roles).map(p => [
|
|
65
|
+
roleLabel(p),
|
|
66
|
+
catalog.kit.filter(e => (e.kit ?? "") === tab && e.role === p && passes(e)),
|
|
67
|
+
]);
|
|
68
|
+
return { tabs, tab, groups };
|
|
69
|
+
}
|
|
37
70
|
/* the voice volume control → gain: quadratic curve, so the middle of the control sounds like half */
|
|
38
71
|
export const gainOf = (pct: number) => (pct / 100) ** 2;
|
|
39
72
|
|
|
@@ -1,84 +1,130 @@
|
|
|
1
1
|
/* What the "studio sem conexão" screen (components/StudioOffline.tsx) says, from what the hub knows
|
|
2
|
-
(cloud/studio/src/presence.mjs):
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
(cloud/studio/src/presence.mjs): why the computer is out and what is bringing it back, as steps (the same stepper
|
|
3
|
+
as the setup's). The computer brings itself back where it can: the studio service retries the connection, and
|
|
4
|
+
with no usable login (signed out, disconnected here) it opens the approval page on that computer by itself
|
|
5
|
+
(studio/runner/runner.ts), so the user's part is one click there, never a terminal or a Claude Code session. The
|
|
6
|
+
`fallback` is what to do only when that doesn't happen after a while. A computer the hub has never seen gets the
|
|
7
|
+
first-time screen instead (the welcome, the plugin to install and the setup's steps: components/FirstConnection.tsx). */
|
|
7
8
|
|
|
8
|
-
import type { RunnerProblem } from "../types/api";
|
|
9
|
+
import type { RunnerProblem, SetupStep } from "../types/api";
|
|
9
10
|
import { formatDayTime } from "./format";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
export
|
|
12
|
+
/* the plugin's marketplace (tools/lib/cli.mjs, tools/package.mjs) */
|
|
13
|
+
export const PLUGIN_INSTALL = "claude plugin install omnivideo --marketplace pedroflp/omni-plugin";
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
export type OfflineAdvice = {
|
|
16
|
+
title: string;
|
|
17
|
+
/* one line: what happened */
|
|
18
|
+
text: string;
|
|
19
|
+
/* what is happening now, in order: done, the one running, what comes next */
|
|
20
|
+
steps: SetupStep[];
|
|
21
|
+
/* ISO time the trouble began: the fallback shows once it has lasted `fallbackAfterMs` */
|
|
22
|
+
since: string | null;
|
|
23
|
+
fallbackAfterMs: number;
|
|
24
|
+
/* last resort, only when the computer didn't recover by itself */
|
|
25
|
+
fallback: string;
|
|
26
|
+
};
|
|
27
|
+
type Seen = { name: string | null; lastSeen: string | null; problem: RunnerProblem | null };
|
|
17
28
|
|
|
18
|
-
const
|
|
29
|
+
const theMachine = (name: string | null | undefined) => (name ? `A máquina “${name}”` : "A máquina que roda o studio");
|
|
19
30
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
/** no computer has ever entered this account, and nothing kept one out: the first time */
|
|
32
|
+
export const isFirstConnection = (o: Seen) => !o.problem && !o.name && !o.lastSeen;
|
|
33
|
+
|
|
34
|
+
/* how long a setup report still counts as "what is happening now" (the CLI writes it on every step) */
|
|
35
|
+
const FRESH_MS = 30 * 60_000;
|
|
36
|
+
|
|
37
|
+
/** The setup the CLI is reporting (tools/lib/setup-progress.mjs), when it is recent enough to be what the user
|
|
38
|
+
* is waiting on: a finished one from last week is just history. */
|
|
39
|
+
export function currentSetup<T extends { updated_at: number }>(
|
|
40
|
+
setup: T | null | undefined,
|
|
41
|
+
now = Date.now(),
|
|
42
|
+
): T | null {
|
|
43
|
+
return setup && now - setup.updated_at < FRESH_MS ? setup : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const step = (id: string, label: string, detail: string, state: SetupStep["state"]): SetupStep => ({
|
|
47
|
+
id,
|
|
48
|
+
label,
|
|
49
|
+
detail,
|
|
50
|
+
state,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/* the three beats every case has: what happened, what the computer is doing, and getting back */
|
|
54
|
+
const steps = (done: [string, string], running: [string, string], next: [string, string]): SetupStep[] => [
|
|
55
|
+
step("happened", done[0], done[1], "done"),
|
|
56
|
+
step("working", running[0], running[1], "running"),
|
|
57
|
+
step("back", next[0], next[1], "pending"),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const APPROVE = "Ela abre sozinha na máquina. Entre com o Google e clique em Conectar.";
|
|
61
|
+
const OPEN_CLAUDE = "Se a janela não abriu, abra o Claude Code na máquina: o plugin abre a página de novo.";
|
|
62
|
+
|
|
63
|
+
export function offlineAdvice(o: Seen): OfflineAdvice {
|
|
25
64
|
const p = o.problem;
|
|
65
|
+
const who = theMachine(p && "host" in p && p.host ? p.host : p && "name" in p && p.name ? p.name : o.name);
|
|
26
66
|
if (p?.reason === "key")
|
|
27
67
|
return {
|
|
28
|
-
title: "
|
|
29
|
-
text: `${
|
|
30
|
-
steps:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
68
|
+
title: "A máquina precisa ser aprovada de novo",
|
|
69
|
+
text: `${who} pediu para entrar de novo.`,
|
|
70
|
+
steps: steps(
|
|
71
|
+
["A máquina pediu para entrar", formatDayTime(p.at)],
|
|
72
|
+
["Aprove na janela que abriu", APPROVE],
|
|
73
|
+
["Reconectar", "Volta sozinho assim que você aprovar."],
|
|
74
|
+
),
|
|
75
|
+
since: p.at,
|
|
76
|
+
fallbackAfterMs: 2 * 60_000,
|
|
77
|
+
fallback: OPEN_CLAUDE,
|
|
34
78
|
};
|
|
35
79
|
if (p?.reason === "revoked")
|
|
36
80
|
return {
|
|
37
|
-
title: "
|
|
38
|
-
text: `${
|
|
39
|
-
steps:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
81
|
+
title: "Esta máquina foi desconectada",
|
|
82
|
+
text: `${who} foi desconectada do studio.`,
|
|
83
|
+
steps: steps(
|
|
84
|
+
["Máquina desconectada", formatDayTime(p.at)],
|
|
85
|
+
["Pedindo para entrar de novo", APPROVE],
|
|
86
|
+
["Reconectar", "Volta sozinho assim que você aprovar."],
|
|
87
|
+
),
|
|
88
|
+
since: p.at,
|
|
89
|
+
fallbackAfterMs: 2 * 60_000,
|
|
90
|
+
fallback: OPEN_CLAUDE,
|
|
43
91
|
};
|
|
44
92
|
if (p?.reason === "protocol" && p.side === "mac")
|
|
45
93
|
return {
|
|
46
|
-
title: "
|
|
47
|
-
text: `${
|
|
48
|
-
steps:
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
94
|
+
title: "A máquina precisa de atualização",
|
|
95
|
+
text: `${who} está com uma versão do Omni anterior à do servidor.`,
|
|
96
|
+
steps: steps(
|
|
97
|
+
["Versões diferentes", "A máquina e o servidor não se entendem."],
|
|
98
|
+
["Atualizando a máquina", "O Omni baixa a versão nova e reinicia o studio sozinho."],
|
|
99
|
+
["Reconectar", "Volta sozinho depois de atualizar."],
|
|
100
|
+
),
|
|
101
|
+
since: p.at,
|
|
102
|
+
fallbackAfterMs: 10 * 60_000,
|
|
103
|
+
fallback: "Está demorando. Abra o Claude Code na máquina: o plugin termina a atualização.",
|
|
52
104
|
};
|
|
53
105
|
if (p?.reason === "protocol")
|
|
54
106
|
return {
|
|
55
107
|
title: "O servidor está desatualizado",
|
|
56
|
-
text: `${
|
|
57
|
-
steps:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
text: "O vídeo é feito no seu computador (a narração, o motion e a montagem), e este studio só mostra e comanda. Nenhum Mac entrou nesta conta ainda.",
|
|
66
|
-
steps: [
|
|
67
|
-
{ text: "Instale o plugin no Claude Code (uma vez):", code: PLUGIN_INSTALL },
|
|
68
|
-
{
|
|
69
|
-
text: "Abra uma sessão do Claude Code. O plugin abre o navegador para você aprovar o Mac, baixa o que o vídeo precisa (alguns minutos) e liga o studio sozinho. Esta tela some quando ele conectar.",
|
|
70
|
-
},
|
|
71
|
-
{ text: "Sem o plugin, pelo terminal:", code: `${CLI} setup` },
|
|
72
|
-
],
|
|
108
|
+
text: `${who} já tem uma versão mais nova que a do servidor.`,
|
|
109
|
+
steps: steps(
|
|
110
|
+
["Versões diferentes", "O servidor está mais velho que a máquina."],
|
|
111
|
+
["Esperando o servidor atualizar", "Acontece do nosso lado, sem você fazer nada."],
|
|
112
|
+
["Reconectar", "Volta sozinho depois da atualização."],
|
|
113
|
+
),
|
|
114
|
+
since: p.at,
|
|
115
|
+
fallbackAfterMs: 30 * 60_000,
|
|
116
|
+
fallback: "Está demorando. Tente de novo em alguns minutos.",
|
|
73
117
|
};
|
|
74
118
|
return {
|
|
75
119
|
title: "O studio perdeu a conexão",
|
|
76
|
-
text: `${
|
|
77
|
-
steps:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
120
|
+
text: o.lastSeen ? `${who} não está conectada desde ${formatDayTime(o.lastSeen)}.` : `${who} não está conectada.`,
|
|
121
|
+
steps: steps(
|
|
122
|
+
["Conexão perdida", o.lastSeen ? `Desde ${formatDayTime(o.lastSeen)}` : "Agora"],
|
|
123
|
+
["Procurando a máquina", "O Omni tenta de novo sozinho, a cada poucos segundos."],
|
|
124
|
+
["Reconectar", "Volta sozinho quando a máquina estiver ligada e com internet."],
|
|
125
|
+
),
|
|
126
|
+
since: o.lastSeen,
|
|
127
|
+
fallbackAfterMs: 10 * 60_000,
|
|
128
|
+
fallback: "Se a máquina está ligada e com internet, abra o Claude Code nela: o plugin religa o studio.",
|
|
83
129
|
};
|
|
84
130
|
}
|