@crievo/omni 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/docs/onboarding.md +69 -161
  2. package/omnivideo.config.example.json +1 -1
  3. package/package.json +2 -1
  4. package/reference/learnings.md +12 -0
  5. package/reference/omnichat.md +5 -4
  6. package/reference/publish.md +9 -9
  7. package/reference/studio.md +1 -1
  8. package/studio/api/app.ts +2 -2
  9. package/studio/api/routes/sound.ts +22 -3
  10. package/studio/api/services/onboarding.ts +242 -21
  11. package/studio/api/services/settings.ts +4 -2
  12. package/studio/api/services/sound.ts +45 -0
  13. package/studio/shared/types/api.ts +26 -1
  14. package/studio/studio.ts +1 -0
  15. package/studio/web/components/AppShell.tsx +3 -1
  16. package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
  17. package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
  18. package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
  19. package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
  20. package/studio/web/components/Omni/OmniWidget.tsx +14 -5
  21. package/studio/web/components/Omni/PendingActions.tsx +25 -6
  22. package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
  23. package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
  24. package/studio/web/components/ResultMessage.tsx +12 -3
  25. package/studio/web/components/Sidebar.tsx +63 -24
  26. package/studio/web/components/StageStepper.tsx +105 -34
  27. package/studio/web/components/Toaster.tsx +25 -0
  28. package/studio/web/components/VoiceCarousel.tsx +19 -16
  29. package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
  30. package/studio/web/components/bencho/ActionNode.tsx +456 -0
  31. package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
  32. package/studio/web/components/bencho/BenchoScope.tsx +8 -0
  33. package/studio/web/components/bencho/Checklist.tsx +421 -0
  34. package/studio/web/components/bencho/CreateMenu.tsx +258 -0
  35. package/studio/web/components/bencho/GenerateImage.tsx +290 -0
  36. package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
  37. package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
  38. package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
  39. package/studio/web/components/bencho/NowPlaying.tsx +403 -0
  40. package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
  41. package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
  42. package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
  43. package/studio/web/components/bencho/SloshSlider.tsx +394 -0
  44. package/studio/web/components/bencho/StackedBars.tsx +342 -0
  45. package/studio/web/components/bencho/ToastStack.tsx +263 -0
  46. package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
  47. package/studio/web/components/bencho/VoiceNote.tsx +705 -0
  48. package/studio/web/components/icons.tsx +27 -0
  49. package/studio/web/components/ui/slider.tsx +34 -1
  50. package/studio/web/components/ui/switch.tsx +18 -5
  51. package/studio/web/hooks/useOnboarding.ts +24 -6
  52. package/studio/web/index.html +1 -1
  53. package/studio/web/lib/alerts.ts +10 -0
  54. package/studio/web/lib/omni-panel.ts +4 -1
  55. package/studio/web/lib/toast.ts +196 -0
  56. package/studio/web/lib/upload.ts +24 -0
  57. package/studio/web/pages/BenchoPage/index.tsx +74 -0
  58. package/studio/web/pages/BenchoPage/registry.ts +174 -0
  59. package/studio/web/pages/GettingStartedPage/components/ComputerCard.tsx +102 -0
  60. package/studio/web/pages/GettingStartedPage/index.tsx +84 -58
  61. package/studio/web/pages/NewVideoPage/index.tsx +6 -2
  62. package/studio/web/pages/ReferencesPage/index.tsx +4 -1
  63. package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +30 -3
  64. package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
  65. package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
  66. package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
  67. package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
  68. package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
  69. package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
  70. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
  71. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
  72. package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
  73. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
  74. package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
  75. package/studio/web/pages/VideoPage/index.tsx +4 -0
  76. package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
  77. package/studio/web/router.tsx +8 -0
  78. package/studio/web/styles/components/bencho/action-node.css +227 -0
  79. package/studio/web/styles/components/bencho/checklist.css +182 -0
  80. package/studio/web/styles/components/bencho/dock.css +188 -0
  81. package/studio/web/styles/components/bencho/gallery.css +109 -0
  82. package/studio/web/styles/components/bencho/generate.css +151 -0
  83. package/studio/web/styles/components/bencho/image-accordion.css +37 -0
  84. package/studio/web/styles/components/bencho/liq-create.css +279 -0
  85. package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
  86. package/studio/web/styles/components/bencho/one-time-code.css +148 -0
  87. package/studio/web/styles/components/bencho/picker.css +204 -0
  88. package/studio/web/styles/components/bencho/pull.css +313 -0
  89. package/studio/web/styles/components/bencho/scope.css +42 -0
  90. package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
  91. package/studio/web/styles/components/bencho/slosh.css +137 -0
  92. package/studio/web/styles/components/bencho/sound.css +260 -0
  93. package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
  94. package/studio/web/styles/components/bencho/toast-stack.css +192 -0
  95. package/studio/web/styles/components/bencho/tokens.css +243 -0
  96. package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
  97. package/studio/web/styles/components/bencho/voice-note.css +337 -0
  98. package/studio/web/styles/components/clone-voice.css +12 -2
  99. package/studio/web/styles/components/omni.css +6 -1
  100. package/studio/web/styles/components/stepper.css +123 -7
  101. package/studio/web/styles/components/toaster.css +10 -0
  102. package/studio/web/styles/components/ui/slider.css +6 -16
  103. package/studio/web/styles/components/ui/switch.css +2 -41
  104. package/studio/web/styles/components/voice-carousel.css +46 -4
  105. package/studio/web/styles/index.css +23 -0
  106. package/studio/web/styles/pages/getting-started.css +46 -0
  107. package/studio/web/styles/pages/settings/shell.css +4 -0
  108. package/studio/web/styles/pages/video/layout.css +1 -4
  109. package/studio/web/styles/pages/video/sound-timeline.css +8 -0
  110. package/studio/web/styles/theme/identity/shell.css +10 -26
  111. package/tools/lib/agents.mjs +24 -13
  112. package/tools/lib/claude-settings.mjs +32 -3
  113. package/tools/lib/deps.mjs +24 -0
  114. package/tools/lib/platforms/instagram.mjs +1 -1
  115. package/tools/lib/up.mjs +5 -2
  116. package/tools/omni.mjs +7 -5
  117. package/tools/omnichat.mjs +5 -7
  118. package/tools/package.mjs +5 -3
  119. package/tools/publish.mjs +40 -137
  120. package/tools/reference.mjs +1 -1
  121. package/tools/setup.mjs +16 -15
  122. package/tools/sound.mjs +67 -3
@@ -1,40 +1,247 @@
1
1
  /* Getting started (/getting-started): the guide in docs/onboarding.md, one step per section
2
- (`<!-- step: id -->`), with each step's state measured by `tools/setup.mjs
3
- check --json`. The guide owns the text: the UI repeats none of it. */
2
+ (`<!-- step: id -->`), each with its state measured here. The guide owns the text: the UI repeats
3
+ none of it.
4
4
 
5
+ The first step is this computer: the four programs the CLI (`omni up`) downloads into ~/.omni in the
6
+ background (checkDeps, from tools/lib/deps.mjs) and the Claude Code permission rules the plugin's
7
+ skills need (missingRules, from tools/lib/claude-settings.mjs; Claude writes them through the
8
+ plugin's MCP, never the studio). The page polls while something is missing, so checkDeps is cached:
9
+ briefly while the download runs, longer once everything answers. The other steps read the
10
+ workspace (.env, config, styles library, videos/): nothing there runs a program.
11
+
12
+ `tools/setup.mjs check` stays the development repository's terminal check; the studio no longer
13
+ runs it. */
14
+
15
+ import { existsSync, readdirSync, statSync } from "node:fs";
5
16
  import { join, resolve, sep } from "node:path";
6
- import { CODE_ROOT, NODE, TOOLS_DIR, run, readText } from "./root";
17
+ import { CODE_ROOT, readText } from "./root";
18
+ import { providerKey, readConfig, readEnv } from "./settings";
19
+ import { LIBRARY_DIR } from "./references";
20
+ import { videoDirs } from "./videos";
21
+ import { checkDeps } from "../../../tools/lib/deps.mjs";
22
+ import { missingRules } from "../../../tools/lib/claude-settings.mjs";
23
+ import { accountsOf, loginOf, tildify } from "../../../tools/lib/agents.mjs";
7
24
 
8
25
  export const DOCS_DIR = join(CODE_ROOT, "docs");
9
26
  const GUIDE = join(DOCS_DIR, "onboarding.md");
10
27
 
11
- type Check = { id: string; title: string; state: "ok" | "missing" | "warning"; detail: string };
12
- type CheckResult = {
13
- ready: boolean;
14
- steps: { id: string; state: Check["state"]; optional?: boolean; items: Check[] }[];
15
- };
28
+ type State = "ok" | "missing" | "warning";
29
+ type Check = { id: string; title: string; state: State; detail: string };
30
+ type StepCheck = { optional?: boolean; items: Check[] };
16
31
  export type OnboardingStep = {
17
32
  id: string;
18
33
  title: string;
19
34
  markdown: string;
20
- state: Check["state"] | null;
35
+ state: State | null;
21
36
  optional: boolean;
22
37
  items: Check[];
23
38
  };
39
+ export type Computer = {
40
+ ffmpeg: string | null;
41
+ ffprobe: string | null;
42
+ chrome: string | null;
43
+ python: string | null;
44
+ /** only the visual style from a link needs it: not part of `ready` */
45
+ ytdlp: string | null;
46
+ /** the four programs answer */
47
+ ready: boolean;
48
+ /** the permission rules still missing in the Claude Code settings (empty = ok) */
49
+ missing_rules: string[];
50
+ /** a development checkout (the code root has .git, as `omni up` tells it): Claude runs the repository's
51
+ own skills, so the plugin's rules don't block anything */
52
+ dev: boolean;
53
+ /** the agent account the sessions run on: logged in (the e-mail) or where the login was looked for */
54
+ agent: { ok: boolean; detail: string };
55
+ };
56
+
57
+ const item = (id: string, title: string, state: State, detail: string): Check => ({ id, title, state, detail });
58
+ const stateOf = (items: Check[]): State =>
59
+ items.some(i => i.state === "missing") ? "missing" : items.some(i => i.state === "warning") ? "warning" : "ok";
60
+
61
+ /* --- this computer ------------------------------------------------------------------- */
24
62
 
25
- /* the check runs programs (bun, ffmpeg, python…): cache it briefly so the overview doesn't pay on every visit */
26
- let cached: { at: number; r: CheckResult } | null = null;
27
- async function runChecks(fresh: boolean): Promise<CheckResult | null> {
28
- if (!fresh && cached && Date.now() - cached.at < 60_000) return cached.r;
29
- const r = await run([NODE, join(TOOLS_DIR, "setup.mjs"), "check", "--json"]);
63
+ /* checkDeps runs each program (spawnSync): one run serves every poll for a while */
64
+ let deps: { at: number; value: ReturnType<typeof checkDeps> } | null = null;
65
+ const DEPS_TTL_PREPARING = 4_000;
66
+ const DEPS_TTL_READY = 5 * 60_000;
67
+ const DEV = existsSync(join(CODE_ROOT, ".git"));
68
+
69
+ export function getComputer(fresh = false): Computer {
70
+ const ready = (d: ReturnType<typeof checkDeps>) => !!(d.ffmpeg && d.ffprobe && d.chrome && d.python);
71
+ const ttl = deps && ready(deps.value) ? DEPS_TTL_READY : DEPS_TTL_PREPARING;
72
+ if (fresh || !deps || Date.now() - deps.at > ttl) deps = { at: Date.now(), value: checkDeps(CODE_ROOT) };
73
+ const d = deps.value;
74
+ let rules: string[];
30
75
  try {
31
- cached = { at: Date.now(), r: JSON.parse(r.stdout) };
32
- return cached.r;
76
+ rules = missingRules();
33
77
  } catch {
34
- return null;
78
+ rules = [];
35
79
  }
80
+ return {
81
+ agent: agentAccount(),
82
+ ffmpeg: d.ffmpeg || null,
83
+ ffprobe: d.ffprobe || null,
84
+ chrome: d.chrome || null,
85
+ python: d.python || null,
86
+ ytdlp: d.ytdlp || null,
87
+ ready: ready(d),
88
+ missing_rules: rules,
89
+ dev: DEV,
90
+ };
91
+ }
92
+
93
+ /* the studio's sessions need one enabled account with a login (tools/lib/agents.mjs): with no list, the
94
+ Claude Code of this machine, which may live in another folder than the one looked at */
95
+ function agentAccount(): Computer["agent"] {
96
+ const on = accountsOf(readConfig()).filter(a => a.enabled);
97
+ const logged = on.find(a => loginOf(a));
98
+ if (logged) return { ok: true, detail: loginOf(logged)?.email ?? tildify(logged.dir) };
99
+ return {
100
+ ok: false,
101
+ detail: on.length ? `sem login em ${on.map(a => tildify(a.dir)).join(", ")}` : "nenhuma conta ligada",
102
+ };
103
+ }
104
+
105
+ const PREPARING = "preparando…";
106
+
107
+ function computerStep(c: Computer): StepCheck {
108
+ const program = (id: keyof Computer, title: string) => {
109
+ const v = c[id] as string | null;
110
+ return item(id, title, v ? "ok" : "missing", v ?? PREPARING);
111
+ };
112
+ return {
113
+ items: [
114
+ program("ffmpeg", "ffmpeg"),
115
+ program("ffprobe", "ffprobe"),
116
+ program("chrome", "Chrome sem janela"),
117
+ program("python", "Python das ferramentas"),
118
+ /* optional: a warning while missing, so it never holds up the computer step */
119
+ item("ytdlp", "yt-dlp", c.ytdlp ? "ok" : "warning", c.ytdlp ?? PREPARING),
120
+ item("agent", "Conta do Claude Code", c.agent.ok ? "ok" : "missing", c.agent.detail),
121
+ item(
122
+ "skills",
123
+ "Permissão das skills no Claude Code",
124
+ c.missing_rules.length && !c.dev ? "missing" : "ok",
125
+ !c.missing_rules.length
126
+ ? "liberadas"
127
+ : c.dev
128
+ ? "repositório de desenvolvimento: as skills são as dele"
129
+ : `faltam ${c.missing_rules.length} regras`,
130
+ ),
131
+ ],
132
+ };
36
133
  }
37
134
 
135
+ /* --- the steps that keep the user busy meanwhile -------------------------------------- */
136
+
137
+ const PROVIDERS = [
138
+ ["fish", "Fish Audio"],
139
+ ["cartesia", "Cartesia"],
140
+ ["elevenlabs", "ElevenLabs"],
141
+ ] as const;
142
+
143
+ function voiceSteps(): Record<"voice-key" | "voice-clone" | "voice-default", StepCheck> {
144
+ const voice = readConfig()?.voice ?? {};
145
+ const withKey = PROVIDERS.filter(([id]) => providerKey(id));
146
+ const profiles: { id: string; name?: string; [provider: string]: unknown }[] = voice.profiles ?? [];
147
+ const nameOf = (p: (typeof profiles)[number]) => String(p.name ?? p.id);
148
+ const usable = profiles.filter(p => withKey.some(([id]) => p[id]));
149
+ const active = profiles.find(p => p.id === voice.default_profile);
150
+ return {
151
+ "voice-key": {
152
+ items: [
153
+ item(
154
+ "voice-key",
155
+ "Chave de um provider de voz",
156
+ withKey.length ? "ok" : "missing",
157
+ withKey.length ? withKey.map(([, name]) => name).join(", ") : "nenhuma chave da Fish Audio ou da Cartesia",
158
+ ),
159
+ ],
160
+ },
161
+ "voice-clone": {
162
+ items: [
163
+ item(
164
+ "voice-profile",
165
+ "Uma voz clonada",
166
+ usable.length ? "ok" : "missing",
167
+ usable.length
168
+ ? usable.map(nameOf).join(", ")
169
+ : profiles.length
170
+ ? "a voz criada não tem o ID de um provider com chave"
171
+ : "nenhuma voz ainda",
172
+ ),
173
+ ],
174
+ },
175
+ "voice-default": {
176
+ items: [
177
+ item(
178
+ "voice-active",
179
+ "Voz padrão do vídeo novo",
180
+ active ? "ok" : "missing",
181
+ active ? nameOf(active) : profiles.length ? "nenhuma voz marcada como padrão" : "clone uma voz antes",
182
+ ),
183
+ ],
184
+ },
185
+ };
186
+ }
187
+
188
+ function platformsStep(): StepCheck {
189
+ const env = readEnv();
190
+ /* the same tokens Configurações → Plataformas reads */
191
+ const on = (id: string, title: string, connected: boolean) =>
192
+ item(id, title, connected ? "ok" : "warning", connected ? "conectado" : "não conectado");
193
+ return {
194
+ optional: true,
195
+ items: [
196
+ on("youtube", "YouTube", !!env.YOUTUBE_REFRESH_TOKEN),
197
+ on("instagram", "Instagram", !!env.INSTAGRAM_TOKEN),
198
+ on("tiktok", "TikTok", !!env.TIKTOK_REFRESH_TOKEN),
199
+ ],
200
+ };
201
+ }
202
+
203
+ function styleStep(): StepCheck {
204
+ let n = 0;
205
+ try {
206
+ n = readdirSync(LIBRARY_DIR).filter(name => {
207
+ if (name.startsWith(".")) return false;
208
+ const dir = join(LIBRARY_DIR, name);
209
+ return statSync(dir).isDirectory() && !existsSync(join(dir, ".disabled"));
210
+ }).length;
211
+ } catch {
212
+ /* no library yet */
213
+ }
214
+ return {
215
+ items: [
216
+ item(
217
+ "style",
218
+ "Um estilo visual",
219
+ n ? "ok" : "missing",
220
+ n ? `${n} na biblioteca de estilos visuais` : "a biblioteca está vazia",
221
+ ),
222
+ ],
223
+ };
224
+ }
225
+
226
+ function firstVideoStep(): StepCheck {
227
+ let done: string | null = null;
228
+ for (const [slug, dir] of videoDirs()) {
229
+ try {
230
+ if (readdirSync(join(dir, "export")).some(f => f.endsWith(".mp4"))) {
231
+ done = slug;
232
+ break;
233
+ }
234
+ } catch {
235
+ /* not exported */
236
+ }
237
+ }
238
+ return {
239
+ items: [item("video", "Um vídeo exportado", done ? "ok" : "missing", done ?? "nenhum ainda")],
240
+ };
241
+ }
242
+
243
+ /* --- the guide --------------------------------------------------------------------------- */
244
+
38
245
  /* an image relative to the guide (onboarding/x.png) becomes the studio's /docs/… route; a link to another
39
246
  repo file becomes the path in code (in the studio it has nowhere to go) */
40
247
  const withImages = (md: string) =>
@@ -44,10 +251,21 @@ const withImages = (md: string) =>
44
251
  text.includes("`") ? text : `${text} (\`${target}\`)`,
45
252
  );
46
253
 
254
+ function checks(computer: Computer): Record<string, StepCheck> {
255
+ return {
256
+ computer: computerStep(computer),
257
+ ...voiceSteps(),
258
+ platforms: platformsStep(),
259
+ style: styleStep(),
260
+ "first-video": firstVideoStep(),
261
+ };
262
+ }
263
+
47
264
  export async function getOnboarding(fresh = false) {
48
265
  const text = readText(GUIDE);
49
266
  if (!text) return { error: "docs/onboarding.md não existe" };
50
- const checks = await runChecks(fresh);
267
+ const computer = getComputer(fresh);
268
+ const all = checks(computer);
51
269
  const [head, ...sections] = text.split(/<!--\s*step:\s*([\w-]+)\s*-->/);
52
270
  /* split interleaves: id, body, id, body… */
53
271
  const steps: OnboardingStep[] = [];
@@ -55,18 +273,21 @@ export async function getOnboarding(fresh = false) {
55
273
  const id = sections[i];
56
274
  const body = sections[i + 1].trim();
57
275
  const m = body.match(/^##\s+(.+)\n/);
58
- const c = checks?.steps.find(p => p.id === id);
276
+ const c = all[id];
59
277
  steps.push({
60
278
  id,
61
279
  title: m ? m[1].trim() : id,
62
280
  markdown: withImages(m ? body.slice(m[0].length) : body).trim(),
63
- state: c?.state ?? null,
281
+ state: c ? stateOf(c.items) : null,
64
282
  optional: !!c?.optional,
65
283
  items: c?.items ?? [],
66
284
  });
67
285
  }
286
+ /* ready for the first video: every required step before it with nothing missing */
287
+ const upTo = steps.findIndex(s => s.id === "first-video");
288
+ const ready = steps.slice(0, upTo < 0 ? steps.length : upTo).every(s => s.optional || s.state !== "missing");
68
289
  const intro = withImages(head.replace(/^#\s+.+\n/, "")).trim();
69
- return { ready: checks?.ready ?? false, checked: !!checks, intro, steps };
290
+ return { ready, checked: true, intro, steps, computer };
70
291
  }
71
292
 
72
293
  /* /docs/<path>: the guide's images */
@@ -78,7 +78,7 @@ export const ENV_KEYS = [
78
78
  ...PUBLISH_ENV_KEYS,
79
79
  ];
80
80
 
81
- function readEnv(): Record<string, string> {
81
+ export function readEnv(): Record<string, string> {
82
82
  const vals: Record<string, string> = {};
83
83
  for (const line of (readText(ENV_FILE) ?? "").split("\n")) {
84
84
  const l = line.trim();
@@ -562,7 +562,8 @@ export function setCartesiaPlan(plan: unknown, quota: unknown, day: unknown): Re
562
562
 
563
563
  /* --- agent accounts --------------------------------------------------------------------
564
564
  `agents.accounts` (N Claude config dirs, N Codex homes) and `agents.codex` (model, effort,
565
- sandbox); the rotation and the limits live in tools/lib/agents.mjs. ~/.claude is refused there. */
565
+ sandbox); the rotation and the limits live in tools/lib/agents.mjs. With no list, the one account is the
566
+ Claude Code this machine uses (~/.claude, or the folder the plugin's hook recorded). */
566
567
  const SANDBOXES = ["auto", "full"];
567
568
 
568
569
  function agentsSettings(cfg: any) {
@@ -579,6 +580,7 @@ function agentsSettings(cfg: any) {
579
580
  limited_until: limitedUntil(ROOT, a.id) || null,
580
581
  login_command: loginCommand(a, cfg),
581
582
  logging_in: logins[a.id] ?? null,
583
+ implicit: a.implicit,
582
584
  })),
583
585
  /* who takes the next new session */
584
586
  next: pickAccount(ROOT, cfg, { advance: false })?.id ?? null,
@@ -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. */
@@ -1042,6 +1042,8 @@ export type AgentAccount = {
1042
1042
  limited_until: number | null;
1043
1043
  login_command: string;
1044
1044
  logging_in: { state: "waiting" | "ok" | "error"; url: string | null; output: string; at: number } | null;
1045
+ /* the default account (no list in the config): the Claude Code this machine uses */
1046
+ implicit: boolean;
1045
1047
  };
1046
1048
  export type AgentsSettings = {
1047
1049
  accounts: AgentAccount[];
@@ -1067,7 +1069,30 @@ export type OnboardingStep = {
1067
1069
  optional: boolean;
1068
1070
  items: OnboardingCheck[];
1069
1071
  };
1070
- export type Onboarding = { ready: boolean; checked: boolean; intro: string; steps: OnboardingStep[]; error?: string };
1072
+ /* this computer: what the CLI downloads into ~/.omni (tools/lib/deps.mjs checkDeps, a version line or
1073
+ path, null while it isn't there yet) and the skills' permission rules (tools/lib/claude-settings.mjs) */
1074
+ export type OnboardingComputer = {
1075
+ ffmpeg: string | null;
1076
+ ffprobe: string | null;
1077
+ chrome: string | null;
1078
+ python: string | null;
1079
+ /* only the visual style from a link needs it: not part of `ready` */
1080
+ ytdlp: string | null;
1081
+ ready: boolean;
1082
+ missing_rules: string[];
1083
+ /* a development checkout: the repository's own skills, the plugin's rules don't block */
1084
+ dev: boolean;
1085
+ /* the agent account the sessions run on: its e-mail, or where no login was found */
1086
+ agent: { ok: boolean; detail: string };
1087
+ };
1088
+ export type Onboarding = {
1089
+ ready: boolean;
1090
+ checked: boolean;
1091
+ intro: string;
1092
+ steps: OnboardingStep[];
1093
+ computer?: OnboardingComputer;
1094
+ error?: string;
1095
+ };
1071
1096
 
1072
1097
  /* --- analytics (tools/analysis.mjs context; metrics from tools/metrics.mjs) ----------------- */
1073
1098
  export type MetricsSnapshot = {
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
  );