@crievo/omni 0.1.8 → 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.
@@ -136,6 +136,7 @@
136
136
  ]
137
137
  },
138
138
  "kit": "kit/sfx",
139
+ "default_kit": "motion-ui",
139
140
  "roles": {
140
141
  "click": {
141
142
  "db": -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crievo/omni",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "OmniVideo no seu computador: o CLI omni, o studio local e as ferramentas do pipeline.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,7 +45,7 @@ the others only read it. A full example is in `templates/scenes.example.json`.
45
45
  | `element` | 3 | name of what enters; becomes the CSS variable `--b-<element>` |
46
46
  | `t` | 3 | provisional time in seconds, used until a voice exists |
47
47
  | `enters_with` | 4 | reference to the line: `c01f2`, `c01f2:end`, `c01f2:palavra`, `c01f2:palavra#2`, with optional `+0.3`/`-0.3`. The word is the spoken (PT) word; `:fim` is still accepted as a legacy alias of `:end` |
48
- | `sound` | 3, optional | effect on the beat: a file name in the global kit (`kit/sfx/kit.json`) or in `<video>/sfx/`, captured from Epidemic and registered in `sfx/sources.json` (`"clique-seco.mp3"`), an object with adjustments, or a list of them (layers at the same instant). Fields below |
48
+ | `sound` | 3, optional | effect on the beat: a file name in a sound kit (`kit/sfx/<kit>/`, `"clique-seco.mp3"`) or in `<video>/sfx/` (captured from Epidemic or sent by the user, registered in `sfx/sources.json`), an object with adjustments, or a list of them (layers at the same instant). Fields below |
49
49
 
50
50
  `tools/timing.mjs` → `beatTime(scene, beat)` resolves it: `enters_with` if
51
51
  present, otherwise `t`.
@@ -54,7 +54,8 @@ present, otherwise `t`.
54
54
 
55
55
  | field | default | what it is |
56
56
  |---|---|---|
57
- | `name` | required | file in the video's `sfx/` or in the global kit (`kit/sfx/`); the video's one wins. From the kit, the role's `db` is added (`sound.roles`) |
57
+ | `name` | required | file in the video's `sfx/` or in a kit; the video's one wins. From a kit, the role's `db` is added (`sound.roles`) |
58
+ | `kit` | the default kit (`sound.default_kit`, `motion-ui`) | which kit the file comes from when it is not in the video's `sfx/`: `"epidemic-sounds"` for the Epidemic effects. A sound never falls back to another kit: a name only in another kit fails and says which one to name |
58
59
  | `anchor` | `"peak"` | which point of the effect lands on the instant: `"peak"` (the loudest sample) or `"start"` |
59
60
  | `offset` | 0 | seconds after the instant (negative: before) |
60
61
  | `db` | 0 | adjustment over the default level: `sound.level_db` below the voice's integrated loudness |
@@ -589,11 +589,11 @@ comes, that it be the studio's own.
589
589
  | `GET /api/videos/<slug>/captions/page` | the video's `captions.html` (`?preview=1` marks the safe area and accepts `postMessage({ position, t })`) |
590
590
  | `GET /api/videos/<slug>/captions/frame?t=` | JPEG of the motion at second `t` of the video, from the scene's MP4 |
591
591
  | `POST /api/videos/<slug>/captions/position` | `{ scope: "video" \| "scene" \| "caption", target?, value }` → `captions` in `scenes.json`; `value: null` removes a scene's or caption's adjustment |
592
- | `GET /api/videos/<slug>/sound` | sound timeline: total, scenes, lines (video time), sound places (each beat with its instant and each scene change, with the sounds), the bed, the catalog (kit by role and the video's `sfx/`, measured) and the export's base MP4 |
592
+ | `GET /api/videos/<slug>/sound` | sound timeline: total, scenes, lines (video time), sound places (each beat with its instant and each scene change, with the sounds), the bed, the catalog (the kits by role, each effect named `kit/name.mp3`, and the video's `sfx/`, measured) and the export's base MP4 |
593
593
  | `POST /api/videos/<slug>/sound/plan` | `{ places, bed }` (draft) → the `sound.mjs` plan with the URL of each finished WAV under `/sound-draft/<slug>/…`; nothing is written to the video |
594
594
  | `POST /api/videos/<slug>/sound` | `{ places, bed }` → `sound` on the beats, `transition.sound` and `bed` in `scenes.json` (validated; defaults are dropped) |
595
595
  | `POST /api/videos/<slug>/sound/reassemble` | runs `assemble.mjs` (waits for its turn in the queue) |
596
- | `GET /kit/<name>` | MP3 from the global kit (`kit/sfx/`), for the waveform |
596
+ | `GET /kit/<kit>%2F<name>` | MP3 from a sound kit (`kit/sfx/<kit>/`), by the catalog's name `kit/name.mp3`, for the waveform |
597
597
  | `GET /api/cost` | `tools/lib/cost.mjs`: what the Claude sessions spent, by video, stage, fork, model and day (`?days=N`); reads `.sessions/`, cache in `.cost/` |
598
598
  | `GET /api/usage` | `tools/usage.py summary --json`: Fish balance, Cartesia quota, usage per video and latest generations (only reads the local log) |
599
599
  | `POST /api/usage/refresh` | the same with `--live`: asks the platforms now and writes the reading to `.usage/` |
@@ -27,6 +27,7 @@ O que você faz, e só isso:
27
27
  - Olhar a composição e o resultado (inspect_content): o quadro de uma cena (ou o do segundo at), a folha de contato dela (o movimento), o corte que a abre, o wireframe, várias cenas lado a lado, o vídeo exportado e os slides do carrossel. Para "essa cena está boa?", pergunte algo objetivo (o elemento principal aparece? o texto é legível?).
28
28
  - Mostrar as coisas abrindo a tela delas, não no seu painel: "mostra as thumbs" é navigate com stage publish e tab thumbnail; "o Omnichat desse vídeo", navigate com screen omnichat_rule e o slug (content carousel para o do carrossel); o vídeo exportado, stage publish; o carrossel, stage carousel-publish; os números, video_metrics. Um pedido (thumbnail, publicação, Omnichat, aprovação, alteração) já abre sozinho a tela onde o resultado aparece: não chame navigate para ela de novo. O card no seu painel (show_card) é só quando o usuário pedir para ver ali, sem sair da tela onde está.
29
29
  - No editor de um vídeo já montado: tocar e pausar (control_editor) e adicionar uma música ou um efeito da Epidemic Sound (add_epidemic_sound, com a busca em inglês, como "calm piano"), só quando o usuário pedir. Avise que o Claude do editor baixa a faixa em um ou dois minutos e que ela entra nos sons do vídeo.
30
+ - Os efeitos vêm de kits (pastas de sons). O padrão é o kit motion-ui (sons de interface sintetizados, do bencho.dev): quando o usuário pede um som sem dizer de onde, é desse kit. O kit epidemic-sounds tem os efeitos capturados da Epidemic. Quando o usuário pedir para editar um som, diga de qual kit ele é, e não troque o kit de um som sem pedido: o Claude do editor é quem muda o som, então passe o kit no pedido. add_epidemic_sound baixa só para este vídeo, não para um kit.
30
31
  - A agenda (get_schedule): o que está agendado e o que foi publicado em cada dia, com a hora, as plataformas e o Omnichat de cada um. Para "o que sai amanhã", "o que tem essa semana", "o que publiquei ontem".
31
32
  - O Omnichat de um conteúdo publicado ou agendado (omnichat_status, set_omnichat): você configura sozinha, sem card. Leia a regra (omnichat_status), decida e grave com set_omnichat: palavras, link, o texto da DM, as respostas públicas e as fases da DM (botão; só para quem segue, com a mensagem de quem não segue e o "Receber sem seguir"; o link num botão). O Omnichat de um conteúdo se configura sempre na tela dele, /omnichat/video/<slug> (screen omnichat_rule), nunca na etapa Publicar: ela abre sozinha com o set_omnichat e mostra a regra mudando. Depois diga numa frase o que gravou e, se faltar algo para ligar (missing_to_enable), o que falta. Ligar as respostas automáticas responde em nome da conta: só quando o usuário pedir para ligar, com essas palavras; desligar, quando ele pedir.
32
33
  - Quando o pedido depende de um link ou de um texto que o usuário precisa colar (um vídeo para virar estilo visual, uma página, um roteiro pronto), chame ask_in_chat e diga numa frase o que colar no chat. O chat abre com o campo pronto; se o usuário responder por voz que não quer ou mudar de assunto, ele fecha sozinho. Não peça para ele ditar um link.
@@ -18,7 +18,8 @@ import {
18
18
  sanitizeBed,
19
19
  sanitizeSpeed,
20
20
  finalClock,
21
- kitDir,
21
+ kitDirOf,
22
+ kitNames,
22
23
  } from "../../../tools/sound.mjs";
23
24
  import { videoClips, lineClips, lineDuration } from "../../../tools/soundtrack.mjs";
24
25
  import { normalizeCuts } from "../../../tools/cuts.mjs";
@@ -28,7 +29,12 @@ import type { ApiResult } from "./sessions";
28
29
  type Result = { ok: true; output: string } | { ok: false; error: string };
29
30
  const DRAFT_DIR = join(ROOT, ".sound-draft");
30
31
 
31
- const normalizeSound = (s: any) => (typeof s === "string" ? { name: s } : { ...s });
32
+ /* a sound from scenes.json: its kit's effect is shown by the ref "kit/name.mp3", as the catalog names it */
33
+ const normalizeSound = (s: any) => {
34
+ const o = typeof s === "string" ? { name: s } : { ...s };
35
+ if (o.kit && typeof o.name === "string" && !o.name.includes("/")) o.name = `${o.kit}/${o.name}`;
36
+ return o;
37
+ };
32
38
  const asList = (sound: any) => (sound == null ? [] : (Array.isArray(sound) ? sound : [sound]).map(normalizeSound));
33
39
 
34
40
  /* the export's base MP4 (without the speed), for the timeline picture, and the cuts it already has */
@@ -164,10 +170,15 @@ export function draftFile(slug: string, rest: string): string | null {
164
170
  return abs.startsWith(base + sep) && abs.endsWith(".wav") && existsSync(abs) ? abs : null;
165
171
  }
166
172
 
167
- /* a file from the global kit, for the waveform */
168
- export function kitFile(name: string): string | null {
169
- const abs = resolve(kitDir, name);
170
- return abs.startsWith(resolve(kitDir) + sep) && abs.endsWith(".mp3") && existsSync(abs) ? abs : null;
173
+ /* a file of a kit, for the waveform: the ref "kit/name.mp3" (the catalog's name for a kit effect) */
174
+ export function kitFile(ref: string): string | null {
175
+ const i = ref.indexOf("/");
176
+ const kit = i > 0 ? ref.slice(0, i) : "";
177
+ const name = i > 0 ? ref.slice(i + 1) : "";
178
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(kit) || !kitNames().includes(kit)) return null;
179
+ const dir = kitDirOf(kit);
180
+ const abs = resolve(dir, name);
181
+ return abs.startsWith(resolve(dir) + sep) && abs.endsWith(".mp3") && existsSync(abs) ? abs : null;
171
182
  }
172
183
 
173
184
  /* writes the draft to scenes.json: the sound of each beat, each transition, the bed and the cuts */
@@ -337,10 +337,11 @@ export type SoundBed = {
337
337
  } | null;
338
338
  /* an area cut (tools/cuts.mjs), in scenes.json time */
339
339
  export type SoundCut = { from: number; to: number };
340
- /* a sound of the kit or of the video's sfx/ */
340
+ /* a sound of a kit or of the video's sfx/ (a kit's effect is named "kit/name.mp3") */
341
341
  export type SoundEffectInfo = {
342
342
  name: string;
343
343
  origin: "kit" | "video";
344
+ kit?: string | null;
344
345
  role: string | null;
345
346
  title: string;
346
347
  music: boolean;
@@ -356,6 +357,7 @@ export type SoundClip = { from: number; to: number; offset: number; speed?: numb
356
357
  export type SoundCatalog = {
357
358
  kit: SoundEffectInfo[];
358
359
  video: SoundEffectInfo[];
360
+ defaultKit?: string;
359
361
  roles: Record<string, { db: number; stretchable?: boolean }>;
360
362
  level_db: number;
361
363
  ducking: { db: number };
@@ -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,7 +1,7 @@
1
1
  /* The sound picker: the kit grouped by role and the sounds captured for this video (music only for the bed). */
2
2
 
3
3
  import { Picker } from "../../../../components/ui/picker";
4
- import { roleLabel, round2 } from "../../../../lib/sound-timeline";
4
+ import { fileName, kitsOf, roleLabel, round2 } from "../../../../lib/sound-timeline";
5
5
  import type { EffectInfo, SoundCatalog } from "./types";
6
6
 
7
7
  export function EffectChoice({
@@ -18,7 +18,7 @@ export function EffectChoice({
18
18
  const fits = (e: EffectInfo) => (bed ? true : !e.music);
19
19
  const roles = Object.keys(catalog.roles);
20
20
  const desc = (e: EffectInfo) =>
21
- `${e.name} · ${e.music ? `${Math.round(e.duration)}s trilha` : round2(e.useful_end_s ?? e.duration)}`;
21
+ `${fileName(e.name)} · ${e.music ? `${Math.round(e.duration)}s trilha` : round2(e.useful_end_s ?? e.duration)}`;
22
22
  return (
23
23
  <Picker
24
24
  aria-label="Som"
@@ -26,12 +26,14 @@ export function EffectChoice({
26
26
  onChange={onChange}
27
27
  placeholder="Escolha um som"
28
28
  options={[
29
- ...roles.flatMap(p => {
30
- const ofRole = catalog.kit.filter(e => e.role === p && fits(e));
31
- return ofRole.length
32
- ? [{ group: `kit · ${roleLabel(p)}`, options: ofRole.map(e => ({ value: e.name, label: desc(e) })) }]
33
- : [];
34
- }),
29
+ ...kitsOf(catalog).flatMap(k =>
30
+ roles.flatMap(p => {
31
+ const ofRole = catalog.kit.filter(e => (e.kit ?? "") === k && e.role === p && fits(e));
32
+ return ofRole.length
33
+ ? [{ group: `${k} · ${roleLabel(p)}`, options: ofRole.map(e => ({ value: e.name, label: desc(e) })) }]
34
+ : [];
35
+ }),
36
+ ),
35
37
  ...(catalog.video.filter(fits).length > 0
36
38
  ? [
37
39
  {
@@ -1,12 +1,13 @@
1
- /* The library: the video's and the kit's sounds, to drag onto the timeline or pick with a click
1
+ /* The library: one tab per kit (the default kit first, open at start) and one for the video's own
2
+ sounds. Inside a tab, the sounds grouped by role. Drag onto the timeline or pick with a click
2
3
  (then click the timeline); ▶ listens to the file. */
3
4
 
4
5
  import { useState } from "react";
5
6
  import { Play } from "../../../../components/icons";
6
7
  import { previewFile } from "../../../../lib/sound-audio";
7
- import { effectSrc, roleLabel, round2 } from "../../../../lib/sound-timeline";
8
+ import { effectSrc, fileName, kitLibrary, round2 } from "../../../../lib/sound-timeline";
8
9
  import { AddSound } from "./AddSound";
9
- import type { EffectInfo, SoundCatalog } from "./types";
10
+ import type { SoundCatalog } from "./types";
10
11
 
11
12
  export function SoundLibrary({
12
13
  catalog,
@@ -24,15 +25,8 @@ export function SoundLibrary({
24
25
  onAdd: (name: string | null) => void;
25
26
  }) {
26
27
  const [filter, setFilter] = useState("");
27
- const f = filter.trim().toLowerCase();
28
- const passes = (e: EffectInfo) =>
29
- !f || `${e.name} ${e.title} ${e.role ?? ""} ${e.music ? "música de fundo cama" : ""}`.toLowerCase().includes(f);
30
- const groups: [string, EffectInfo[]][] = [
31
- ["deste vídeo", catalog.video.filter(passes)],
32
- ...Object.keys(catalog.roles).map(
33
- p => [`kit · ${roleLabel(p)}`, catalog.kit.filter(e => e.role === p && passes(e))] as [string, EffectInfo[]],
34
- ),
35
- ];
28
+ const [picked, setPicked] = useState<string | null>(null);
29
+ const { tabs, tab, groups } = kitLibrary(catalog, picked, filter);
36
30
  return (
37
31
  <section className="sound-library">
38
32
  <header>
@@ -46,14 +40,31 @@ export function SoundLibrary({
46
40
  </header>
47
41
  <p className="hint">Arraste para a timeline, ou clique num som e depois na timeline. ▶ ouve o arquivo.</p>
48
42
  <AddSound slug={slug} onAdd={onAdd} />
43
+ <div className="segmented small sound-kit-tabs" role="tablist" aria-label="Kits de som">
44
+ {tabs.map(t => (
45
+ <button
46
+ key={t.id}
47
+ type="button"
48
+ role="tab"
49
+ aria-selected={tab === t.id}
50
+ className={tab === t.id ? "active" : ""}
51
+ onClick={() => setPicked(t.id)}
52
+ >
53
+ {t.name}
54
+ <span className="tab-count">{t.n}</span>
55
+ </button>
56
+ ))}
57
+ </div>
49
58
  <div className="sound-library-list">
50
59
  {groups
51
60
  .filter(([, l]) => l.length)
52
61
  .map(([name, l]) => (
53
- <div key={name} className="sound-group">
54
- <h5>
55
- {name} <span className="subtle">{l.length}</span>
56
- </h5>
62
+ <div key={name || "video"} className="sound-group">
63
+ {name && (
64
+ <h5>
65
+ {name} <span className="subtle">{l.length}</span>
66
+ </h5>
67
+ )}
57
68
  <div className="sound-cards">
58
69
  {l.map(e => (
59
70
  <div
@@ -86,13 +97,15 @@ export function SoundLibrary({
86
97
  : round2(e.useful_end_s ?? e.duration)}
87
98
  </span>
88
99
  </span>
89
- <span className="sound-item-name">{e.name.replace(/\.mp3$/, "")}</span>
100
+ <span className="sound-item-name">{fileName(e.name).replace(/\.mp3$/, "")}</span>
90
101
  </div>
91
102
  ))}
92
103
  </div>
93
104
  </div>
94
105
  ))}
95
- {groups.every(([, l]) => !l.length) && <p className="empty">Nenhum som{f ? " com esse filtro" : ""}</p>}
106
+ {groups.every(([, l]) => !l.length) && (
107
+ <p className="empty">Nenhum som{filter.trim() ? " com esse filtro" : ""}</p>
108
+ )}
96
109
  </div>
97
110
  </section>
98
111
  );
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);