@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.
- package/omnivideo.config.example.json +1 -0
- package/package.json +1 -1
- package/reference/scenes-contract.md +3 -2
- package/reference/studio.md +2 -2
- package/studio/api/services/agent-persona.ts +1 -0
- package/studio/api/services/sound.ts +17 -6
- package/studio/shared/types/api.ts +3 -1
- package/studio/web/lib/sound-timeline.ts +33 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/EffectChoice.tsx +10 -8
- package/studio/web/pages/VideoPage/components/SoundTimeline/SoundLibrary.tsx +31 -18
- package/tools/setup.mjs +18 -5
- package/tools/sound.mjs +198 -94
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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 |
|
package/reference/studio.md
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
168
|
-
export function kitFile(
|
|
169
|
-
const
|
|
170
|
-
|
|
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
|
|
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
|
-
...
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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:
|
|
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,
|
|
8
|
+
import { effectSrc, fileName, kitLibrary, round2 } from "../../../../lib/sound-timeline";
|
|
8
9
|
import { AddSound } from "./AddSound";
|
|
9
|
-
import type {
|
|
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
|
|
28
|
-
const
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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) &&
|
|
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
|
-
|
|
283
|
-
const
|
|
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
|
-
|
|
397
|
-
|
|
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(
|
|
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]
|
|
9
|
-
node tools/sound.mjs kit adopt videos/<slug> <name> --role <role> [--as <name-in-kit>]
|
|
10
|
-
moves an effect
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
sfx/ (sfx/sources.json
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
config
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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 =
|
|
359
|
+
const kitSources = kit => readJson(kitRegistry(kit));
|
|
338
360
|
|
|
339
|
-
|
|
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
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (
|
|
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:
|
|
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
|
-
|
|
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:
|
|
388
|
+
return { file, kit: entry };
|
|
355
389
|
}
|
|
356
390
|
|
|
357
391
|
/* `download` destination: a video dir or the kit ("kit", with --role) */
|
|
358
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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]}${
|
|
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`] ||
|
|
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
|
|
495
|
-
|
|
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:
|
|
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(
|
|
509
|
-
|
|
510
|
-
|
|
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(
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
);
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
744
|
-
if (!
|
|
745
|
-
const {
|
|
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
|
|
772
|
-
const
|
|
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
|
-
|
|
860
|
-
|
|
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
|
|
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")
|
|
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);
|