@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.
Files changed (76) hide show
  1. package/omnivideo.config.example.json +2 -0
  2. package/package.json +1 -1
  3. package/reference/cloud.md +15 -1
  4. package/reference/learnings.md +7 -0
  5. package/reference/scenes-contract.md +3 -2
  6. package/reference/studio.md +3 -2
  7. package/studio/api/app.ts +4 -0
  8. package/studio/api/routes/cost.ts +13 -0
  9. package/studio/api/routes/me.ts +8 -0
  10. package/studio/api/services/agent-persona.ts +1 -0
  11. package/studio/api/services/agent-tools.ts +2 -2
  12. package/studio/api/services/cost.ts +22 -0
  13. package/studio/api/services/me.ts +67 -0
  14. package/studio/api/services/sessions.ts +8 -2
  15. package/studio/api/services/sound.ts +17 -6
  16. package/studio/runner/runner.ts +57 -10
  17. package/studio/shared/omni-screens.ts +1 -1
  18. package/studio/shared/schemas/cost.ts +10 -0
  19. package/studio/shared/types/api.ts +77 -1
  20. package/studio/studio.ts +27 -2
  21. package/studio/web/components/AppShell.tsx +11 -3
  22. package/studio/web/components/FirstConnection.tsx +222 -0
  23. package/studio/web/components/OfflineNotice.tsx +59 -0
  24. package/studio/web/components/SetupSteps.tsx +63 -0
  25. package/studio/web/components/Sidebar.tsx +10 -25
  26. package/studio/web/components/StudioOffline.tsx +48 -50
  27. package/studio/web/components/UserCard.tsx +79 -0
  28. package/studio/web/components/icons.tsx +2 -0
  29. package/studio/web/hooks/useComputers.ts +55 -0
  30. package/studio/web/hooks/useCost.ts +11 -0
  31. package/studio/web/hooks/useMe.ts +32 -0
  32. package/studio/web/hooks/useSetupProgress.ts +54 -0
  33. package/studio/web/lib/me.ts +17 -0
  34. package/studio/web/lib/query-client.ts +6 -0
  35. package/studio/web/lib/settings-categories.ts +2 -2
  36. package/studio/web/lib/setup-retry.ts +29 -0
  37. package/studio/web/lib/sound-timeline.ts +33 -0
  38. package/studio/web/lib/studio-offline.ts +105 -59
  39. package/studio/web/pages/ConnectPreviewPage/index.tsx +215 -0
  40. package/studio/web/pages/SettingsPage/components/AgentCategory.tsx +18 -9
  41. package/studio/web/pages/SettingsPage/components/AgentCostPanel.tsx +253 -0
  42. package/studio/web/pages/SettingsPage/components/ComputersPanel.tsx +145 -0
  43. package/studio/web/pages/SettingsPage/components/PublishingCategory/OmnichatSettingsPanel.tsx +2 -2
  44. package/studio/web/pages/SettingsPage/components/PublishingCategory/SchedulePanel.tsx +3 -3
  45. package/studio/web/pages/SettingsPage/components/UsageCategory.tsx +7 -2
  46. package/studio/web/pages/SettingsPage/index.tsx +15 -1
  47. package/studio/web/pages/VideoPage/components/PublishForm.tsx +5 -2
  48. package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +10 -8
  49. package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +31 -18
  50. package/studio/web/router.tsx +8 -0
  51. package/studio/web/styles/components/setup-steps.css +163 -0
  52. package/studio/web/styles/components/user-card.css +121 -0
  53. package/studio/web/styles/index.css +2 -0
  54. package/studio/web/styles/pages/settings/shell.css +79 -0
  55. package/studio/web/styles/pages/settings/usage.css +47 -0
  56. package/studio/web/styles/pages/studio-offline.css +224 -38
  57. package/tools/cost.mjs +114 -0
  58. package/tools/lib/agents.mjs +3 -2
  59. package/tools/lib/channel.mjs +1 -1
  60. package/tools/lib/cloud-user.mjs +58 -0
  61. package/tools/lib/cost.mjs +357 -0
  62. package/tools/lib/deps.mjs +27 -12
  63. package/tools/lib/oauth-client.mjs +28 -14
  64. package/tools/lib/pages-style.mjs +67 -0
  65. package/tools/lib/pairing.mjs +1 -1
  66. package/tools/lib/schedule/schedule.mjs +1 -1
  67. package/tools/lib/self-update.mjs +122 -0
  68. package/tools/lib/setup-progress.mjs +315 -0
  69. package/tools/lib/up.mjs +176 -61
  70. package/tools/metrics.mjs +6 -1
  71. package/tools/omni.mjs +41 -16
  72. package/tools/omnichat.mjs +45 -9
  73. package/tools/platforms.mjs +75 -14
  74. package/tools/runner.mjs +6 -6
  75. package/tools/setup.mjs +18 -5
  76. package/tools/sound.mjs +198 -94
package/tools/runner.mjs CHANGED
@@ -67,7 +67,7 @@ async function login() {
67
67
  fail(`${e.message}; rode de novo node tools/runner.mjs login`),
68
68
  );
69
69
  writeEnv({ [KEY]: paired.key });
70
- console.log(`Mac pareado como "${paired.name}". A chave ficou no .env (${KEY}).`);
70
+ console.log(`Máquina pareada como "${paired.name}". A chave ficou no .env (${KEY}).`);
71
71
  console.log(
72
72
  service().loaded
73
73
  ? "O studio (serviço) conecta sozinho em até 30 s."
@@ -119,25 +119,25 @@ async function status() {
119
119
  if (!out.url) return console.log("Studio hospedado: não configurado (cloud.studio_url no config).");
120
120
  console.log(`Studio hospedado: ${out.url}`);
121
121
  console.log(serviceLine());
122
- if (!out.hasKey) return console.log("Este Mac: não pareado (node tools/runner.mjs login).");
122
+ if (!out.hasKey) return console.log("Esta máquina: não pareada (node tools/runner.mjs login).");
123
123
  if (!out.paired)
124
124
  return console.log(
125
- `Este Mac: a chave não vale mais (${out.error}). O studio aberto pareia de novo sozinho (aprove na página que ele abre), ou: node tools/runner.mjs login`,
125
+ `Esta máquina: a chave não vale mais (${out.error}). O studio aberto pareia de novo sozinho (aprove na página que ele abre), ou: node tools/runner.mjs login`,
126
126
  );
127
127
  console.log(
128
- `Este Mac: pareado como "${out.name}", ${out.online ? "conectado agora" : "desconectado (o studio está aberto? veja o serviço acima)"}.`,
128
+ `Esta máquina: pareada como "${out.name}", ${out.online ? "conectada agora" : "desconectada (o studio está aberto? veja o serviço acima)"}.`,
129
129
  );
130
130
  }
131
131
 
132
132
  async function logout() {
133
133
  const url = studioUrl();
134
134
  const token = env(KEY);
135
- if (!token) return console.log("Este Mac já não está pareado.");
135
+ if (!token) return console.log("Esta máquina já não está pareada.");
136
136
  const r = await call(url, "/runner/logout", { token });
137
137
  writeEnv({ [KEY]: "" });
138
138
  if (!r.ok && r.status !== 401)
139
139
  fail(`a chave saiu do .env, mas o studio não confirmou (${r.status}: ${r.body?.error ?? "?"})`);
140
- console.log("Mac desconectado do studio hospedado. Para voltar: node tools/runner.mjs login");
140
+ console.log("Máquina desconectada do studio hospedado. Para voltar: node tools/runner.mjs login");
141
141
  }
142
142
 
143
143
  const [cmd, sub] = args;
package/tools/setup.mjs CHANGED
@@ -49,6 +49,13 @@ const readJson = file => {
49
49
  return null;
50
50
  }
51
51
  };
52
+ /* the sound kits: the folders under kit/sfx/ (a kit is a folder with its kit.json) */
53
+ const listKits = dir =>
54
+ existsSync(dir)
55
+ ? readdirSync(dir)
56
+ .filter(k => statSync(join(dir, k)).isDirectory())
57
+ .sort()
58
+ : [];
52
59
  const toPath = p => resolve(ROOT, String(p).replace(/^~(?=\/|$)/, homedir()));
53
60
  const cfg = () => readJson(CONFIG);
54
61
 
@@ -279,8 +286,12 @@ function check() {
279
286
 
280
287
  /* 6. sound: the kit may start empty, but then Claude searches Epidemic through Chrome */
281
288
  {
282
- const kit = readJson(join(ROOT, "kit", "sfx", "kit.json")) ?? {};
283
- const n = Object.keys(kit).length;
289
+ /* every kit folder under kit/sfx/ (motion-ui, epidemic-sounds…) counts its effects */
290
+ const kits = listKits(join(ROOT, "kit", "sfx"));
291
+ const n = kits.reduce(
292
+ (sum, k) => sum + Object.keys(readJson(join(ROOT, "kit", "sfx", k, "kit.json")) ?? {}).length,
293
+ 0,
294
+ );
284
295
  /* any Claude account that already connected the extension */
285
296
  const ext = accountsOf(readJson(CONFIG) ?? {})
286
297
  .filter(a => a.provider === "claude")
@@ -393,11 +404,13 @@ function prepare() {
393
404
  mkdirSync(library, { recursive: true });
394
405
  done.push(`criei a biblioteca de estilos visuais em ${library}`);
395
406
  }
396
- const kit = join(ROOT, "kit", "sfx");
397
- if (!existsSync(join(kit, "kit.json"))) {
407
+ /* the kits: motion-ui is the default; epidemic-sounds holds the effects captured from Epidemic */
408
+ for (const name of ["motion-ui", "epidemic-sounds"]) {
409
+ const kit = join(ROOT, "kit", "sfx", name);
410
+ if (existsSync(join(kit, "kit.json"))) continue;
398
411
  mkdirSync(kit, { recursive: true });
399
412
  writeFileSync(join(kit, "kit.json"), "{}\n");
400
- done.push("criei o kit de efeitos vazio (kit/sfx/kit.json)");
413
+ done.push(`criei o kit de efeitos vazio (kit/sfx/${name}/kit.json)`);
401
414
  }
402
415
  mkdirSync(join(ROOT, "videos"), { recursive: true });
403
416
 
package/tools/sound.mjs CHANGED
@@ -4,29 +4,26 @@
4
4
 
5
5
  node tools/sound.mjs capture [n] prints the capture code (Chrome)
6
6
  node tools/sound.mjs download videos/<slug> <url> [name] [--search <term>]
7
- node tools/sound.mjs download kit <url> <name> --role <role> [--search <term>]
8
- node tools/sound.mjs kit [role|text] lists the global kit by role
9
- node tools/sound.mjs kit adopt videos/<slug> <name> --role <role> [--as <name-in-kit>]
10
- moves an effect captured for the video into the kit
7
+ node tools/sound.mjs download kit <url> <name> --role <role> [--kit <kit>] [--search <term>]
8
+ node tools/sound.mjs kit [role|text] [--kit <kit>] lists the kits (or one) by role
9
+ node tools/sound.mjs kit adopt videos/<slug> <name> --role <role> [--kit <kit>] [--as <name-in-kit>]
10
+ moves an effect downloaded for the video into a kit
11
+ node tools/sound.mjs kit add <kit> <file> --role <role> [--as <name>] [--title <text>]
12
+ a file the user chose becomes an MP3 of that kit
11
13
  node tools/sound.mjs import videos/<slug> <file> [name] [--title <text>]
12
14
  a track the user sent (the editor's upload), as an MP3 of the video's sfx/
13
15
  node tools/sound.mjs effects videos/<slug> [text] lists the effects captured for the video
14
16
  node tools/sound.mjs videos/<slug> prepares the video's sound/
15
17
 
16
- Every effect comes from Epidemic Sound, through one of two registries: the
17
- global kit (sound.kit in the config, kit/sfx/kit.json), curated once and used
18
- by every video, each effect with a role (click, pop, tick, typing, confirm,
19
- whoosh, transition, riser, impact, glitch, bed, texture); and the video's
20
- sfx/ (sfx/sources.json), for what the kit lacks. No other audio saved on the
21
- computer gets in, except a track the user sends on purpose through the
22
- editor's upload (`import`): it goes to the video's sfx/ as an MP3 with
23
- source "upload", only for that video, never into the kit. With a search open in Chrome (sound.source.search in the
24
- config), the `capture` code plays the previews and returns each one's URL
25
- (audiocdn.epidemicsound.com/lqmp3/<id>.mp3); `download` writes the MP3 to
26
- the kit or the video's sfx/ and records the origin. An effect outside both
27
- registries is refused. The name is looked up in the video first, then in the
28
- kit. From the kit, the effect brings its role's volume (sound.roles in the
29
- config, or its own `db` in kit.json), added to the sound's `db`.
18
+ Kits: one folder each under kit/sfx/ (its kit.json lists the effects, with
19
+ role and db), curated per sound palette. The default kit (sound.default_kit in
20
+ the config, motion-ui) is what a sound without `kit` uses; a scene's sound names
21
+ another with `"kit": "<name>"` (epidemic-sounds, for the effects captured from
22
+ Epidemic). The video's own sfx/ (sfx/sources.json, captured or sent by the user)
23
+ always comes first. A sound never falls back to another kit silently: if the
24
+ name lives only in another kit, the error says which one to name. Any provider's
25
+ audio can be in a kit; from a kit, the effect brings its role's volume (sound.roles
26
+ in the config, or its own `db` in kit.json), added to the sound's `db`.
30
27
 
31
28
  Where sound goes in scenes.json:
32
29
  beat.sound at the beat's instant
@@ -83,7 +80,7 @@
83
80
  queue turn (tools/job-queue.mjs).
84
81
  ============================================================================ */
85
82
 
86
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
83
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, statSync, readdirSync } from "node:fs";
87
84
  import { spawnSync } from "node:child_process";
88
85
  import { join, resolve, basename, relative } from "node:path";
89
86
  import { fileURLToPath } from "node:url";
@@ -124,7 +121,33 @@ const ROLES = {
124
121
  texture: { db: 0, stretchable: true },
125
122
  ...CFG.sound?.roles,
126
123
  };
127
- const KIT = resolve(ROOT, CFG.sound?.kit ?? "kit/sfx");
124
+ /* the kits: one folder each under KITS (its kit.json lists the effects). The default kit is what a sound
125
+ without `kit` uses; the video's own sfx/ always comes first (effectFile) */
126
+ const KITS = resolve(ROOT, CFG.sound?.kit ?? "kit/sfx");
127
+ const DEFAULT_KIT = CFG.sound?.default_kit ?? "motion-ui";
128
+ const KIT_NAME = /^[a-z0-9][a-z0-9-]*$/;
129
+ const kitDirOf = kit => join(KITS, kit);
130
+ const kitRegistry = kit => join(kitDirOf(kit), "kit.json");
131
+ /* a sound's file: its name and kit, or the ref the studio shows, "kit/name.mp3" (the kit comes first) */
132
+ const parseRef = (name, kit) => {
133
+ const i = typeof name === "string" ? name.indexOf("/") : -1;
134
+ return i < 0 ? { name, kit: kit ?? null } : { name: name.slice(i + 1), kit: name.slice(0, i) };
135
+ };
136
+ /* the kits that exist: each folder under KITS with a valid name */
137
+ const kitNames = () =>
138
+ existsSync(KITS)
139
+ ? readdirSync(KITS)
140
+ .filter(k => KIT_NAME.test(k) && statSync(join(KITS, k)).isDirectory())
141
+ .sort()
142
+ : [];
143
+ /* a kit named by a command: valid, and existing unless `create` (kit add makes one) */
144
+ function checkKit(kit, { create = false } = {}) {
145
+ const k = kit ?? DEFAULT_KIT;
146
+ if (!KIT_NAME.test(k)) throw new Error(`kit “${k}”: use letras minúsculas, números e hífen (ex.: motion-ui)`);
147
+ if (!create && !existsSync(kitDirOf(k)))
148
+ throw new Error(`o kit “${k}” não existe (kits: ${kitNames().join(", ") || "nenhum"})`);
149
+ return k;
150
+ }
128
151
  const CDN = /^https:\/\/audiocdn\.epidemicsound\.com\/lqmp3\/([0-9A-Za-z]+)\.mp3$/;
129
152
  const NAME = /^[a-z0-9][a-z0-9._-]*\.mp3$/i;
130
153
  const MUSIC_S = 30; /* above this it is a soundtrack, not an effect (the bed may) */
@@ -327,47 +350,60 @@ export function measureEffect(file) {
327
350
  }
328
351
 
329
352
  /* --- where an effect comes from --------------------------------------------------
330
- Only from Epidemic, through two registries: the global kit (sound.kit in the
331
- config, kit/sfx/ with kit.json), curated once and used by every video, and
332
- the video's sfx/ (sfx/sources.json), for sound the kit lacks. The name is
333
- looked up in the video first, then in the kit. */
353
+ Two places: the video's sfx/ (sfx/sources.json: captured for the video, or sent by the user), which
354
+ always wins, and one kit (kit/sfx/<kit>/kit.json): the sound's `kit`, else the default kit. A sound
355
+ never falls back to another kit silently: if the name is only in another kit, the error says so. */
334
356
  const effectsDir = dir => join(resolve(dir), "sfx");
335
357
  const readJson = f => (existsSync(f) ? JSON.parse(readFileSync(f, "utf8")) : {});
336
358
  const sources = dir => readJson(join(effectsDir(dir), "sources.json"));
337
- const kitSources = () => readJson(join(KIT, "kit.json"));
359
+ const kitSources = kit => readJson(kitRegistry(kit));
338
360
 
339
- export function effectFile(dir, name) {
361
+ /* the file of an effect for a video, and the kit entry (with its kit's name) when it comes from a kit */
362
+ export function effectFile(dir, name, kit) {
363
+ ({ name, kit } = parseRef(name, kit));
340
364
  if (typeof name !== "string" || !NAME.test(name) || basename(name) !== name)
341
- return { error: `efeito “${name}”: o nome é um MP3 do sfx/ do vídeo ou do kit (ex.: clique-seco.mp3)` };
342
- const [from, source, where] = sources(dir)[name]
343
- ? [effectsDir(dir), sources(dir)[name], "sfx/sources.json"]
344
- : kitSources()[name]
345
- ? [KIT, kitSources()[name], "o kit"]
346
- : [];
347
- if (!from)
365
+ return { error: `efeito “${name}”: o nome é um MP3 do sfx/ do vídeo ou de um kit (ex.: clique-seco.mp3)` };
366
+ const k = kit ?? DEFAULT_KIT;
367
+ if (!KIT_NAME.test(k)) return { error: `kit “${k}”: use letras minúsculas, números e hífen` };
368
+ let file,
369
+ entry = null,
370
+ where;
371
+ if (sources(dir)[name]) {
372
+ file = join(effectsDir(dir), name);
373
+ where = "sfx/sources.json";
374
+ } else if (kitSources(k)[name]) {
375
+ file = join(kitDirOf(k), name);
376
+ entry = { ...kitSources(k)[name], kitName: k };
377
+ where = `o kit ${k}`;
378
+ } else {
379
+ const elsewhere = kitNames().find(o => o !== k && kitSources(o)[name]);
348
380
  return {
349
- error: `efeito “${name}” não está no kit nem foi capturado da Epidemic para este vídeo (falta em sfx/sources.json): use um do kit (sound.mjs kit) ou capture com sound.mjs capture e download`,
381
+ error: elsewhere
382
+ ? `efeito “${name}” não está no kit ${k}, mas está no kit ${elsewhere}: ponha "kit": "${elsewhere}" no som`
383
+ : `efeito “${name}” não está no kit ${k} nem no sfx/ deste vídeo (falta em sfx/sources.json): use um do kit (sound.mjs kit --kit ${k}) ou baixe com sound.mjs download`,
350
384
  };
351
- const file = join(from, name);
385
+ }
352
386
  if (!existsSync(file))
353
387
  return { error: `efeito “${name}” está em ${where}, mas o arquivo sumiu: rode sound.mjs download de novo` };
354
- return { file, kit: from === KIT ? source : null };
388
+ return { file, kit: entry };
355
389
  }
356
390
 
357
391
  /* `download` destination: a video dir or the kit ("kit", with --role) */
358
- function destination(videoDir, role) {
392
+ /* `download` destination: a video dir, or a kit ("kit", with --role and --kit) */
393
+ function destination(videoDir, role, kit) {
359
394
  if (videoDir === "kit") {
360
395
  if (!ROLES[role]) throw new Error(`no kit, --role é obrigatório: ${Object.keys(ROLES).join(", ")}`);
361
- return { dir: KIT, registry: join(KIT, "kit.json"), extra: { role } };
396
+ const k = checkKit(kit);
397
+ return { dir: kitDirOf(k), registry: kitRegistry(k), extra: { role }, kit: k };
362
398
  }
363
399
  const dir = resolve(videoDir ?? "");
364
400
  if (!existsSync(join(dir, "scenes.json")))
365
401
  throw new Error(`${dir} não é a pasta de um vídeo (falta scenes.json) nem "kit"`);
366
- return { dir: effectsDir(dir), registry: join(effectsDir(dir), "sources.json"), extra: {} };
402
+ return { dir: effectsDir(dir), registry: join(effectsDir(dir), "sources.json"), extra: {}, kit: null };
367
403
  }
368
404
 
369
- async function download(videoDir, url, name, search, role) {
370
- const { dir, registry, extra } = destination(videoDir, role);
405
+ async function download(videoDir, url, name, search, role, kit) {
406
+ const { dir, registry, extra, kit: k } = destination(videoDir, role, kit);
371
407
  const m = CDN.exec(url ?? "");
372
408
  if (!m)
373
409
  throw new Error(
@@ -376,14 +412,15 @@ async function download(videoDir, url, name, search, role) {
376
412
  name = `${(name ?? m[1]).replace(/\.mp3$/i, "")}.mp3`;
377
413
  if (!NAME.test(name)) throw new Error(`nome “${name}”: letras, números, ponto, hífen e sublinhado`);
378
414
  const list = readJson(registry);
379
- const whereAlready = dir === KIT ? "no kit" : "no vídeo";
415
+ const whereAlready = k ? `no kit ${k}` : "no vídeo";
380
416
  const other = Object.entries(list).find(([n, f]) => f.id === m[1] && n !== name);
381
417
  if (other) throw new Error(`esse efeito já está ${whereAlready} como “${other[0]}”`);
382
418
  if (list[name] && list[name].id !== m[1])
383
419
  throw new Error(
384
420
  `já existe “${name}” ${whereAlready} com outro efeito (${list[name].title || list[name].id}): escolha outro nome`,
385
421
  );
386
- if (dir !== KIT && kitSources()[name]) console.log(` ⚠ o kit já tem um “${name}”; o do vídeo passa na frente dele`);
422
+ const clash = !k && kitNames().find(o => kitSources(o)[name]);
423
+ if (clash) console.log(` ⚠ o kit ${clash} já tem um “${name}”; o do vídeo passa na frente dele`);
387
424
 
388
425
  const r = await fetch(url);
389
426
  if (!r.ok) throw new Error(`a Epidemic respondeu ${r.status} para ${url}`);
@@ -402,7 +439,7 @@ async function download(videoDir, url, name, search, role) {
402
439
  ...extra,
403
440
  };
404
441
  writeFileSync(registry, JSON.stringify(list, null, 1) + "\n");
405
- console.log(`${name} ← ${med.title || m[1]}${extra.role ? ` · kit, papel ${extra.role}` : ""}`);
442
+ console.log(`${name} ← ${med.title || m[1]}${k ? ` · kit ${k}, papel ${extra.role}` : ""}`);
406
443
  console.log(
407
444
  med.music
408
445
  ? ` ⚠ ${med.duration.toFixed(0)}s: é trilha, não efeito; só serve de música de fundo`
@@ -426,7 +463,9 @@ function importUpload(videoDir, file, wanted, title) {
426
463
  .replace(/[^a-z0-9._-]+/g, "-")
427
464
  .replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "musica";
428
465
  const taken = n =>
429
- !!sources(dir)[`${n}.mp3`] || !!kitSources()[`${n}.mp3`] || existsSync(join(effectsDir(dir), `${n}.mp3`));
466
+ !!sources(dir)[`${n}.mp3`] ||
467
+ kitNames().some(o => !!kitSources(o)[`${n}.mp3`]) ||
468
+ existsSync(join(effectsDir(dir), `${n}.mp3`));
430
469
  let n = slug;
431
470
  for (let i = 2; taken(n); i++) n = `${slug}-${i}`;
432
471
  const name = `${n}.mp3`;
@@ -491,55 +530,109 @@ function effects(videoDir, filter) {
491
530
  }
492
531
  }
493
532
 
494
- /* moves an effect already captured from Epidemic for a video into the kit, with its origin */
495
- function adopt(videoDir, name, role, as) {
533
+ /* moves an effect already downloaded for a video into the kit, with its origin (never a user's upload) */
534
+ /* moves an effect already downloaded for a video into a kit, with its origin (never a user's upload) */
535
+ function adopt(videoDir, name, role, as, kit) {
496
536
  const dir = resolve(videoDir ?? "");
497
537
  if (!ROLES[role]) throw new Error(`--role é obrigatório: ${Object.keys(ROLES).join(", ")}`);
538
+ const k = checkKit(kit);
498
539
  const source = sources(dir)[name];
499
540
  if (!source) throw new Error(`“${name}” não está em ${join(effectsDir(dir), "sources.json")}`);
500
541
  if (source.source === "upload")
501
- throw new Error(`“${name}” foi enviado pelo usuário para este vídeo: o kit só recebe áudio da Epidemic`);
542
+ throw new Error(`“${name}” foi enviado pelo usuário para este vídeo: esse áudio não vai para o kit`);
502
543
  const newName = `${(as ?? name).replace(/\.mp3$/i, "")}.mp3`;
503
544
  if (!NAME.test(newName)) throw new Error(`nome “${newName}”: letras, números, ponto, hífen e sublinhado`);
504
- const list = kitSources();
545
+ const list = kitSources(k);
505
546
  const other = Object.entries(list).find(([, e]) => e.id === source.id);
506
- if (other) throw new Error(`esse efeito já está no kit como “${other[0]}”`);
547
+ if (other) throw new Error(`esse efeito já está no kit ${k} como “${other[0]}”`);
507
548
  if (list[newName])
508
- throw new Error(`o kit já tem “${newName}” (${list[newName].title || list[newName].id}): use --as <outro-nome>`);
509
- mkdirSync(KIT, { recursive: true });
510
- writeFileSync(join(KIT, newName), readFileSync(join(effectsDir(dir), name)));
549
+ throw new Error(
550
+ `o kit ${k} já tem “${newName}” (${list[newName].title || list[newName].id}): use --as <outro-nome>`,
551
+ );
552
+ writeFileSync(join(kitDirOf(k), newName), readFileSync(join(effectsDir(dir), name)));
511
553
  list[newName] = { ...source, role, adopted_from: basename(dir) };
512
- writeFileSync(join(KIT, "kit.json"), JSON.stringify(list, null, 1) + "\n");
513
- console.log(`${newName} ← ${source.title || source.id} · kit, papel ${role}`);
554
+ writeFileSync(kitRegistry(k), JSON.stringify(list, null, 1) + "\n");
555
+ console.log(`${newName} ← ${source.title || source.id} · kit ${k}, papel ${role}`);
556
+ }
557
+
558
+ /* a file the user chose (their own pack, a library they point at) becomes an MP3 of a kit, with its role.
559
+ Nothing is picked on its own: the path comes from the command. A new kit is made here. */
560
+ function addToKit(kit, file, role, as, title) {
561
+ const k = checkKit(kit, { create: true });
562
+ if (!ROLES[role]) throw new Error(`--role é obrigatório: ${Object.keys(ROLES).join(", ")}`);
563
+ if (!file || !existsSync(file)) throw new Error(`o arquivo “${file}” não existe`);
564
+ const base = (as || basename(file)).replace(/\.[A-Za-z0-9]{1,5}$/, "");
565
+ const slug =
566
+ base
567
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
568
+ .normalize("NFD")
569
+ .replace(/[\u0300-\u036f]/g, "")
570
+ .toLowerCase()
571
+ .replace(/[^a-z0-9._-]+/g, "-")
572
+ .replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "efeito";
573
+ const list = kitSources(k);
574
+ let n = slug;
575
+ for (let i = 2; list[`${n}.mp3`] || existsSync(join(kitDirOf(k), `${n}.mp3`)); i++) n = `${slug}-${i}`;
576
+ const name = `${n}.mp3`;
577
+ mkdirSync(kitDirOf(k), { recursive: true });
578
+ const out = join(kitDirOf(k), name);
579
+ const isMp3 = /\.mp3$/i.test(file);
580
+ run("ffmpeg", [
581
+ "-y",
582
+ "-v",
583
+ "error",
584
+ "-i",
585
+ file,
586
+ "-vn",
587
+ "-map_metadata",
588
+ "-1",
589
+ ...(isMp3 ? ["-c:a", "copy"] : ["-c:a", "libmp3lame", "-q:a", "2"]),
590
+ out,
591
+ ]);
592
+ cache.delete(out);
593
+ list[name] = {
594
+ title: title || basename(file).replace(/\.[A-Za-z0-9]{1,5}$/, ""),
595
+ role,
596
+ added_at: new Date().toISOString().slice(0, 10),
597
+ };
598
+ writeFileSync(kitRegistry(k), JSON.stringify(list, null, 1) + "\n");
599
+ console.log(`${name} ← ${list[name].title} · kit ${k}, papel ${role}`);
600
+ console.log(` ${describe(measureEffect(out))}`);
514
601
  }
515
602
 
516
603
  /* the kit grouped by role; `filter` is a role or a piece of the name/title */
517
- function kit(filter) {
518
- const list = Object.entries(kitSources());
519
- if (!list.length) {
520
- console.log(`o kit está vazio (${KIT}): baixe com sound.mjs download kit <url> <nome> --role <papel>`);
521
- return;
522
- }
604
+ /* the kits grouped by role; `filter` is a role or a piece of the name/title; `only` names one kit */
605
+ function kit(filter, only) {
606
+ const names = only ? [checkKit(only)] : kitNames();
523
607
  const f = filter?.toLowerCase();
524
- for (const role of Object.keys(ROLES)) {
525
- const ofRole = list.filter(
526
- ([n, e]) => e.role === role && (!f || f === role || `${n} ${e.title}`.toLowerCase().includes(f)),
527
- );
528
- if (!ofRole.length) continue;
529
- const p = ROLES[role];
530
- console.log(`${role} (db ${p.db}${p.stretchable ? ", estica" : ""})`);
531
- for (const [name, e] of ofRole) {
532
- const file = join(KIT, name);
533
- if (!existsSync(file)) {
534
- console.log(` ${name} ⚠ arquivo sumiu`);
535
- continue;
536
- }
537
- const m = measureEffect(file);
538
- console.log(
539
- ` ${name.padEnd(28)} ${m.music ? `${m.duration.toFixed(0)}s · trilha` : describe(m)}${e.db != null ? ` · db próprio ${e.db}` : ""}`,
608
+ let any = false;
609
+ for (const k of names) {
610
+ const list = Object.entries(kitSources(k));
611
+ if (!list.length) continue;
612
+ any = true;
613
+ console.log(`== kit ${k}${k === DEFAULT_KIT ? " (padrão)" : ""} ==`);
614
+ for (const role of Object.keys(ROLES)) {
615
+ const ofRole = list.filter(
616
+ ([n, e]) => e.role === role && (!f || f === role || `${n} ${e.title}`.toLowerCase().includes(f)),
540
617
  );
618
+ if (!ofRole.length) continue;
619
+ const p = ROLES[role];
620
+ console.log(`${role} (db ${p.db}${p.stretchable ? ", estica" : ""})`);
621
+ for (const [name, e] of ofRole) {
622
+ const file = join(kitDirOf(k), name);
623
+ if (!existsSync(file)) {
624
+ console.log(` ${name} ⚠ arquivo sumiu`);
625
+ continue;
626
+ }
627
+ const m = measureEffect(file);
628
+ console.log(
629
+ ` ${name.padEnd(28)} ${m.music ? `${m.duration.toFixed(0)}s · trilha` : describe(m)}${e.db != null ? ` · db próprio ${e.db}` : ""}`,
630
+ );
631
+ }
541
632
  }
542
633
  }
634
+ if (!any)
635
+ console.log(`os kits estão vazios (${KITS}): adicione com sound.mjs kit add <kit> <arquivo> --role <papel>`);
543
636
  }
544
637
 
545
638
  function capture(n) {
@@ -740,13 +833,15 @@ function pair(v, name, max, nullAtEnd = false) {
740
833
  }
741
834
 
742
835
  export function sanitizeSound(dir, s) {
743
- const o = typeof s === "string" ? { name: s } : s;
744
- if (!o || typeof o !== "object") throw new Error("som inválido");
745
- const { file, error } = effectFile(dir, o.name);
836
+ const given = typeof s === "string" ? { name: s } : s;
837
+ if (!given || typeof given !== "object") throw new Error("som inválido");
838
+ const o = { ...given, ...parseRef(given.name, given.kit) };
839
+ const { file, error, kit } = effectFile(dir, o.name, o.kit);
746
840
  if (error) throw new Error(error);
747
841
  const m = measureEffect(file);
748
842
  if (m.music) throw new Error(`“${o.name}” tem ${m.duration.toFixed(0)}s: é trilha, só serve de música de fundo`);
749
843
  const v = { name: o.name };
844
+ if (kit) v.kit = kit.kitName;
750
845
  if (o.anchor === "start") v.anchor = "start";
751
846
  else if (o.anchor != null && o.anchor !== "peak") throw new Error(`âncora: ${o.anchor} (use peak ou start)`);
752
847
  for (const k of ["offset", "db", "dur", "speed", "pitch"]) {
@@ -768,10 +863,12 @@ export function sanitizeSound(dir, s) {
768
863
 
769
864
  export function sanitizeBed(dir, b, total) {
770
865
  if (b == null) return null;
771
- const o = typeof b === "string" ? { name: b } : b;
772
- const { error } = effectFile(dir, o?.name);
866
+ const given = typeof b === "string" ? { name: b } : b;
867
+ const o = { ...given, ...parseRef(given.name, given.kit) };
868
+ const { error, kit } = effectFile(dir, o.name, o.kit);
773
869
  if (error) throw new Error(`música de fundo: ${error}`);
774
870
  const v = { name: o.name };
871
+ if (kit) v.kit = kit.kitName;
775
872
  if (o.db != null && +o.db !== 0) v.db = number(o.db, [-40, 20], "música de fundo db");
776
873
  if (o.from != null && +o.from > 0) v.from = number(o.from, [0, total], "música de fundo from");
777
874
  if (o.to != null) v.to = number(o.to, [0, total], "música de fundo to");
@@ -839,13 +936,15 @@ export function sanitizeSpeed(s) {
839
936
 
840
937
  /* what the studio offers: the kit by role and what was captured for the video, measured */
841
938
  export function soundCatalog(dir) {
842
- const item = (from, name, e, origin) => {
939
+ const item = (from, name, e, origin, kit = null) => {
843
940
  const file = join(from, name);
844
941
  if (!existsSync(file)) return null;
845
942
  const m = measureEffect(file);
846
943
  return {
847
- name,
944
+ /* a kit's effect is named by its ref, "kit/name.mp3": the studio shows and sends that one */
945
+ name: kit ? `${kit}/${name}` : name,
848
946
  origin,
947
+ kit,
849
948
  role: e.role ?? null,
850
949
  title: e.title ?? "",
851
950
  music: m.music,
@@ -856,19 +955,21 @@ export function soundCatalog(dir) {
856
955
  };
857
956
  };
858
957
  return {
859
- kit: Object.entries(kitSources())
860
- .map(([n, e]) => item(KIT, n, e, "kit"))
958
+ kits: kitNames().map(k => ({ name: k, default: k === DEFAULT_KIT, count: Object.keys(kitSources(k)).length })),
959
+ kit: kitNames()
960
+ .flatMap(k => Object.entries(kitSources(k)).map(([n, e]) => item(kitDirOf(k), n, e, "kit", k)))
861
961
  .filter(Boolean),
862
962
  video: Object.entries(sources(dir))
863
963
  .map(([n, e]) => item(effectsDir(dir), n, e, "video"))
864
964
  .filter(Boolean),
865
965
  roles: ROLES,
966
+ defaultKit: DEFAULT_KIT,
866
967
  level_db: S.level_db,
867
968
  ducking: S.ducking,
868
969
  bed: S.bed,
869
970
  };
870
971
  }
871
- export const kitDir = KIT;
972
+ export { KITS, DEFAULT_KIT, kitDirOf, kitNames };
872
973
 
873
974
  /* --- the bed's clock -----------------------------------------------------------------
874
975
  The bed is isolated: the editor's area cuts and the speed (`cuts` and `speed` at the root of
@@ -914,7 +1015,7 @@ export function planSound(dir, video, { outDir, scenes = null, withBed = true, c
914
1015
  for (const p of requests(video)) {
915
1016
  if (scenes && !scenes.has(p.scene)) continue;
916
1017
  const { s, id } = p;
917
- const { file, error, kit } = effectFile(dir, s.name);
1018
+ const { file, error, kit } = effectFile(dir, s.name, s.kit);
918
1019
  if (error) throw new Error(`${id}: ${error}`);
919
1020
  const m = measureEffect(file);
920
1021
  if (m.music)
@@ -1023,7 +1124,7 @@ export function planSound(dir, video, { outDir, scenes = null, withBed = true, c
1023
1124
  layer: e.layer,
1024
1125
  scene: e.scene,
1025
1126
  element: e.element,
1026
- name: e.name,
1127
+ name: e.kit ? `${e.kit.kitName}/${e.name}` : e.name,
1027
1128
  file: wav,
1028
1129
  wav: relative(dir, wav),
1029
1130
  start: +e.start.toFixed(3),
@@ -1041,6 +1142,7 @@ export function planSound(dir, video, { outDir, scenes = null, withBed = true, c
1041
1142
  cut_start: +e.cut.toFixed(3),
1042
1143
  ...(e.gave_way ? { gave_way: e.gave_way } : {}),
1043
1144
  origin: e.kit ? "kit" : "video",
1145
+ kit: e.kit?.kitName ?? null,
1044
1146
  role: e.kit?.role ?? null,
1045
1147
  kit_db: e.kitDb,
1046
1148
  });
@@ -1120,7 +1222,7 @@ function giveWay(a, b) {
1120
1222
  function makeBed(dir, video, { outDir, voice, ducking, warnings, clock }) {
1121
1223
  const b = typeof video.bed === "string" ? { name: video.bed } : video.bed;
1122
1224
  const total = video.total_duration;
1123
- const { file, error } = effectFile(dir, b.name);
1225
+ const { file, error } = effectFile(dir, b.name, b.kit);
1124
1226
  if (error) throw new Error(`música de fundo: ${error}`);
1125
1227
  const start = clock.at(Math.max(0, num(b.from, 0))),
1126
1228
  end = clock.at(Math.min(total, num(b.to, total)));
@@ -1358,13 +1460,15 @@ if (isMainModule(import.meta.url)) {
1358
1460
  const option = n => (i => (i < 0 ? undefined : rest.splice(i, 2)[1]))(rest.indexOf(`--${n}`));
1359
1461
  const search = option("search"),
1360
1462
  role = option("role"),
1361
- title = option("title");
1463
+ title = option("title"),
1464
+ kitOpt = option("kit");
1362
1465
  if (cmd === "capture") capture(+(rest[0] ?? 8));
1363
- else if (cmd === "download") await download(rest[0], rest[1], rest[2], search, role);
1466
+ else if (cmd === "download") await download(rest[0], rest[1], rest[2], search, role, kitOpt);
1364
1467
  else if (cmd === "import") importUpload(rest[0], rest[1], rest[2], title);
1365
1468
  else if (cmd === "effects") effects(rest[0], rest[1]);
1366
- else if (cmd === "kit" && rest[0] === "adopt") adopt(rest[1], rest[2], role, option("as") ?? rest[3]);
1367
- else if (cmd === "kit") kit(rest[0]);
1469
+ else if (cmd === "kit" && rest[0] === "adopt") adopt(rest[1], rest[2], role, option("as") ?? rest[3], kitOpt);
1470
+ else if (cmd === "kit" && rest[0] === "add") addToKit(rest[1], rest[2], role, option("as") ?? rest[3], title);
1471
+ else if (cmd === "kit") kit(rest[0], kitOpt);
1368
1472
  else if (cmd && existsSync(join(resolve(cmd), "scenes.json"))) {
1369
1473
  await turn("sound", cmd);
1370
1474
  prepareSound(cmd);