@crievo/omni 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/onboarding.md +69 -161
- package/omnivideo.config.example.json +1 -1
- package/package.json +2 -1
- package/reference/learnings.md +12 -0
- package/reference/omnichat.md +5 -4
- package/reference/publish.md +9 -9
- package/reference/studio.md +1 -1
- package/studio/api/app.ts +2 -2
- package/studio/api/routes/sound.ts +22 -3
- package/studio/api/services/onboarding.ts +242 -21
- package/studio/api/services/settings.ts +4 -2
- package/studio/api/services/sound.ts +45 -0
- package/studio/shared/types/api.ts +26 -1
- package/studio/studio.ts +1 -0
- package/studio/web/components/AppShell.tsx +3 -1
- package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
- package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
- package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
- package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
- package/studio/web/components/Omni/OmniWidget.tsx +14 -5
- package/studio/web/components/Omni/PendingActions.tsx +25 -6
- package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
- package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
- package/studio/web/components/ResultMessage.tsx +12 -3
- package/studio/web/components/Sidebar.tsx +63 -24
- package/studio/web/components/StageStepper.tsx +105 -34
- package/studio/web/components/Toaster.tsx +25 -0
- package/studio/web/components/VoiceCarousel.tsx +19 -16
- package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
- package/studio/web/components/bencho/ActionNode.tsx +456 -0
- package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
- package/studio/web/components/bencho/BenchoScope.tsx +8 -0
- package/studio/web/components/bencho/Checklist.tsx +421 -0
- package/studio/web/components/bencho/CreateMenu.tsx +258 -0
- package/studio/web/components/bencho/GenerateImage.tsx +290 -0
- package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
- package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
- package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
- package/studio/web/components/bencho/NowPlaying.tsx +403 -0
- package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
- package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
- package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
- package/studio/web/components/bencho/SloshSlider.tsx +394 -0
- package/studio/web/components/bencho/StackedBars.tsx +342 -0
- package/studio/web/components/bencho/ToastStack.tsx +263 -0
- package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
- package/studio/web/components/bencho/VoiceNote.tsx +705 -0
- package/studio/web/components/icons.tsx +27 -0
- package/studio/web/components/ui/slider.tsx +34 -1
- package/studio/web/components/ui/switch.tsx +18 -5
- package/studio/web/hooks/useOnboarding.ts +24 -6
- package/studio/web/index.html +1 -1
- package/studio/web/lib/alerts.ts +10 -0
- package/studio/web/lib/omni-panel.ts +4 -1
- package/studio/web/lib/toast.ts +196 -0
- package/studio/web/lib/upload.ts +24 -0
- package/studio/web/pages/BenchoPage/index.tsx +74 -0
- package/studio/web/pages/BenchoPage/registry.ts +174 -0
- package/studio/web/pages/GettingStartedPage/components/ComputerCard.tsx +102 -0
- package/studio/web/pages/GettingStartedPage/index.tsx +84 -58
- package/studio/web/pages/NewVideoPage/index.tsx +6 -2
- package/studio/web/pages/ReferencesPage/index.tsx +4 -1
- package/studio/web/pages/SettingsPage/components/AgentAccounts.tsx +30 -3
- package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
- package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
- package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
- package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
- package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
- package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
- package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
- package/studio/web/pages/VideoPage/index.tsx +4 -0
- package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
- package/studio/web/router.tsx +8 -0
- package/studio/web/styles/components/bencho/action-node.css +227 -0
- package/studio/web/styles/components/bencho/checklist.css +182 -0
- package/studio/web/styles/components/bencho/dock.css +188 -0
- package/studio/web/styles/components/bencho/gallery.css +109 -0
- package/studio/web/styles/components/bencho/generate.css +151 -0
- package/studio/web/styles/components/bencho/image-accordion.css +37 -0
- package/studio/web/styles/components/bencho/liq-create.css +279 -0
- package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
- package/studio/web/styles/components/bencho/one-time-code.css +148 -0
- package/studio/web/styles/components/bencho/picker.css +204 -0
- package/studio/web/styles/components/bencho/pull.css +313 -0
- package/studio/web/styles/components/bencho/scope.css +42 -0
- package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
- package/studio/web/styles/components/bencho/slosh.css +137 -0
- package/studio/web/styles/components/bencho/sound.css +260 -0
- package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
- package/studio/web/styles/components/bencho/toast-stack.css +192 -0
- package/studio/web/styles/components/bencho/tokens.css +243 -0
- package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
- package/studio/web/styles/components/bencho/voice-note.css +337 -0
- package/studio/web/styles/components/clone-voice.css +12 -2
- package/studio/web/styles/components/omni.css +6 -1
- package/studio/web/styles/components/stepper.css +123 -7
- package/studio/web/styles/components/toaster.css +10 -0
- package/studio/web/styles/components/ui/slider.css +6 -16
- package/studio/web/styles/components/ui/switch.css +2 -41
- package/studio/web/styles/components/voice-carousel.css +46 -4
- package/studio/web/styles/index.css +23 -0
- package/studio/web/styles/pages/getting-started.css +46 -0
- package/studio/web/styles/pages/settings/shell.css +4 -0
- package/studio/web/styles/pages/video/layout.css +1 -4
- package/studio/web/styles/pages/video/sound-timeline.css +8 -0
- package/studio/web/styles/theme/identity/shell.css +10 -26
- package/tools/lib/agents.mjs +24 -13
- package/tools/lib/claude-settings.mjs +32 -3
- package/tools/lib/deps.mjs +24 -0
- package/tools/lib/platforms/instagram.mjs +1 -1
- package/tools/lib/up.mjs +5 -2
- package/tools/omni.mjs +7 -5
- package/tools/omnichat.mjs +5 -7
- package/tools/package.mjs +5 -3
- package/tools/publish.mjs +40 -137
- package/tools/reference.mjs +1 -1
- package/tools/setup.mjs +16 -15
- package/tools/sound.mjs +67 -3
package/tools/publish.mjs
CHANGED
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
|
|
67
67
|
/* The platform clients (tools/lib/platforms/), the texts and per-platform checks (tools/lib/publish/)
|
|
68
68
|
and the schedule decisions (tools/lib/schedule/) are the core; this file is the CLI: files, flags,
|
|
69
|
-
the desktop login, the
|
|
69
|
+
the desktop login, the Instagram files through the cloud's R2, ffmpeg/ffprobe, `claude` and the launchd agent. */
|
|
70
70
|
|
|
71
71
|
import {
|
|
72
72
|
readFileSync,
|
|
@@ -74,7 +74,6 @@ import {
|
|
|
74
74
|
renameSync,
|
|
75
75
|
existsSync,
|
|
76
76
|
statSync,
|
|
77
|
-
createReadStream,
|
|
78
77
|
readdirSync,
|
|
79
78
|
mkdirSync,
|
|
80
79
|
openSync,
|
|
@@ -83,9 +82,8 @@ import {
|
|
|
83
82
|
unlinkSync,
|
|
84
83
|
} from "node:fs";
|
|
85
84
|
import { createServer } from "node:http";
|
|
86
|
-
import https from "node:https";
|
|
87
85
|
import { randomBytes, createHash } from "node:crypto";
|
|
88
|
-
import {
|
|
86
|
+
import { spawnSync } from "node:child_process";
|
|
89
87
|
import { homedir, tmpdir } from "node:os";
|
|
90
88
|
import { join, resolve, dirname, basename, sep } from "node:path";
|
|
91
89
|
import { fileURLToPath } from "node:url";
|
|
@@ -98,7 +96,6 @@ import {
|
|
|
98
96
|
pullTiktokEnv,
|
|
99
97
|
cloudSettings,
|
|
100
98
|
cloudApi,
|
|
101
|
-
sleep,
|
|
102
99
|
instagramToken,
|
|
103
100
|
instagramAccount,
|
|
104
101
|
igClient,
|
|
@@ -303,138 +300,44 @@ async function authInstagram(token) {
|
|
|
303
300
|
console.log(`Instagram conectado: @${c.username}`);
|
|
304
301
|
}
|
|
305
302
|
|
|
306
|
-
/*
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
} catch {
|
|
316
|
-
return false;
|
|
317
|
-
}
|
|
318
|
-
if (!ip) return false;
|
|
319
|
-
return new Promise(ok => {
|
|
320
|
-
const req = https.get(
|
|
321
|
-
url,
|
|
322
|
-
{
|
|
323
|
-
headers: { range: "bytes=0-0" },
|
|
324
|
-
lookup: (_h, opts, cb) => (opts?.all ? cb(null, [{ address: ip, family: 4 }]) : cb(null, ip, 4)),
|
|
325
|
-
timeout: 8000,
|
|
326
|
-
},
|
|
327
|
-
res => {
|
|
328
|
-
res.resume();
|
|
329
|
-
ok(res.statusCode === 206 || res.statusCode === 200);
|
|
330
|
-
},
|
|
303
|
+
/* Files at a public URL until Instagram has them: each one goes up to the cloud's R2 (cloud/omnichat,
|
|
304
|
+
PUT /api/media/<slug>/<name>) and Instagram downloads it from <cloud>/media/<key>. `urls` in file
|
|
305
|
+
order; `close()` deletes them (the bucket's 7-day rule is the backstop). The schedule uses the same
|
|
306
|
+
bucket (ADR 0004). */
|
|
307
|
+
async function servePublic(slug, files) {
|
|
308
|
+
const c = cloudSettings();
|
|
309
|
+
if (!c.ready)
|
|
310
|
+
throw new Error(
|
|
311
|
+
"o Instagram baixa o vídeo de um link público, que vem da cloud: falta cloud.url no config e OMNICHAT_CLOUD_SECRET no .env",
|
|
331
312
|
);
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/* Files at a public URL for a few minutes: a server on 127.0.0.1 that only answers
|
|
341
|
-
/<token>.<ext> for each one (with Range) and a cloudflared quick tunnel in front
|
|
342
|
-
(https://<random>.trycloudflare.com). `urls` in file order; `close()` tears both down.
|
|
343
|
-
Local only: in the cloud the files are already at a public URL (object storage). */
|
|
344
|
-
const TYPES = { ".mp4": "video/mp4", ".jpg": "image/jpeg" };
|
|
345
|
-
async function servePublic(files) {
|
|
346
|
-
const served = new Map(
|
|
347
|
-
files.map(file => {
|
|
348
|
-
const ext = file.slice(file.lastIndexOf("."));
|
|
349
|
-
return [
|
|
350
|
-
`/${randomBytes(16).toString("hex")}${ext}`,
|
|
351
|
-
{ file, size: statSync(file).size, type: TYPES[ext] ?? "application/octet-stream" },
|
|
352
|
-
];
|
|
353
|
-
}),
|
|
354
|
-
);
|
|
355
|
-
const srv = createServer((req, res) => {
|
|
356
|
-
const s = served.get(req.url);
|
|
357
|
-
if (!s || !["GET", "HEAD"].includes(req.method)) {
|
|
358
|
-
res.writeHead(404).end();
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
const { file, size } = s;
|
|
362
|
-
let start = 0,
|
|
363
|
-
end = size - 1,
|
|
364
|
-
code = 200;
|
|
365
|
-
const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
|
|
366
|
-
if (m && (m[1] || m[2])) {
|
|
367
|
-
code = 206;
|
|
368
|
-
if (m[1]) {
|
|
369
|
-
start = +m[1];
|
|
370
|
-
if (m[2]) end = Math.min(+m[2], size - 1);
|
|
371
|
-
} else start = Math.max(0, size - +m[2]);
|
|
372
|
-
if (start > end) {
|
|
373
|
-
res.writeHead(416, { "content-range": `bytes */${size}` }).end();
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
res.writeHead(code, {
|
|
378
|
-
"content-type": s.type,
|
|
379
|
-
"content-length": String(end - start + 1),
|
|
380
|
-
"accept-ranges": "bytes",
|
|
381
|
-
...(code === 206 ? { "content-range": `bytes ${start}-${end}/${size}` } : {}),
|
|
382
|
-
});
|
|
383
|
-
if (req.method === "HEAD") res.end();
|
|
384
|
-
else createReadStream(file, { start, end }).pipe(res);
|
|
385
|
-
});
|
|
386
|
-
await new Promise(ok => srv.listen(0, "127.0.0.1", ok));
|
|
387
|
-
const tunnel = spawn(
|
|
388
|
-
P.instagram.cloudflared ?? "cloudflared",
|
|
389
|
-
["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${srv.address().port}`],
|
|
390
|
-
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
391
|
-
);
|
|
392
|
-
const close = () => {
|
|
393
|
-
try {
|
|
394
|
-
tunnel.kill();
|
|
395
|
-
} catch {
|
|
396
|
-
/* already exited */
|
|
397
|
-
}
|
|
398
|
-
srv.close();
|
|
313
|
+
/* the Worker takes [\w-]+ for the slug and [\w-]+.(mp4|jpg) for the name */
|
|
314
|
+
const safe = (/** @type {string} */ s) => s.replace(/[^\w-]+/g, "-");
|
|
315
|
+
const nameOf = (/** @type {string} */ file) => {
|
|
316
|
+
const b = basename(file);
|
|
317
|
+
const dot = b.lastIndexOf(".");
|
|
318
|
+
return `${safe(b.slice(0, dot))}${b.slice(dot).toLowerCase()}`;
|
|
399
319
|
};
|
|
320
|
+
const keys = [];
|
|
321
|
+
const close = () =>
|
|
322
|
+
Promise.all(
|
|
323
|
+
keys.map(k => cloudApi("DELETE", `/api/media/${k}`).catch(e => warn(`não apaguei ${k} da cloud: ${e.message}`))),
|
|
324
|
+
);
|
|
400
325
|
try {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (m) ok(m[0]);
|
|
408
|
-
};
|
|
409
|
-
tunnel.stdout.on("data", check);
|
|
410
|
-
tunnel.stderr.on("data", check);
|
|
411
|
-
check();
|
|
412
|
-
tunnel.on("error", e => reject(new Error(`cloudflared: ${e.message} (instale com brew install cloudflared)`)));
|
|
413
|
-
tunnel.on("exit", c => reject(new Error(`o cloudflared saiu com ${c}: ${output.trim().slice(-300)}`)));
|
|
414
|
-
setTimeout(() => reject(new Error(`o cloudflared não ${label} em 30 s`)), 30_000).unref();
|
|
326
|
+
const urls = [];
|
|
327
|
+
for (const file of files) {
|
|
328
|
+
const r = await fetch(`${c.url}/api/media/${safe(slug)}/${nameOf(file)}`, {
|
|
329
|
+
method: "PUT",
|
|
330
|
+
headers: { authorization: `Bearer ${env("OMNICHAT_CLOUD_SECRET")}` },
|
|
331
|
+
body: readFileSync(file),
|
|
415
332
|
});
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
output += b;
|
|
421
|
-
});
|
|
422
|
-
const base = await waitFor(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/, "abriu o túnel");
|
|
423
|
-
await waitFor(/Registered tunnel connection/, "conectou o túnel");
|
|
424
|
-
/* The new name takes one to two minutes to answer. The proof is downloading 1 byte from
|
|
425
|
-
the public URL, with the IP taken from a public DNS: macOS's DNS caches the "does not
|
|
426
|
-
exist" of the first attempt and would delay everything. */
|
|
427
|
-
const urls = [...served.keys()].map(path => `${base}${path}`);
|
|
428
|
-
const host = new URL(base).hostname;
|
|
429
|
-
const deadline = Date.now() + 150_000;
|
|
430
|
-
for (;;) {
|
|
431
|
-
if (await reachableFromOutside(urls[0], host)) break;
|
|
432
|
-
if (Date.now() > deadline) throw new Error(`o túnel abriu, mas ${host} não respondeu em 2,5 min`);
|
|
433
|
-
await sleep(3000);
|
|
333
|
+
const j = await r.json().catch(() => ({}));
|
|
334
|
+
if (!r.ok || !j.key) throw new Error(`subir ${basename(file)} para a cloud: ${r.status} ${j.error ?? ""}`.trim());
|
|
335
|
+
keys.push(j.key);
|
|
336
|
+
urls.push(j.url ?? `${c.url}/media/${j.key}`);
|
|
434
337
|
}
|
|
435
338
|
return { urls, close };
|
|
436
339
|
} catch (e) {
|
|
437
|
-
close();
|
|
340
|
+
await close();
|
|
438
341
|
throw e;
|
|
439
342
|
}
|
|
440
343
|
}
|
|
@@ -443,12 +346,12 @@ async function servePublic(files) {
|
|
|
443
346
|
video_url (resumable upload is only for apps with Facebook login). The link comes from
|
|
444
347
|
servePublic and stays up until Instagram finishes processing. The cover goes the same
|
|
445
348
|
way, as cover_url, instead of the cover_at_s frame. */
|
|
446
|
-
async function uploadInstagram(mp4, caption, cover) {
|
|
349
|
+
async function uploadInstagram(slug, mp4, caption, cover) {
|
|
447
350
|
const { token, id } = await instagramToken();
|
|
448
351
|
console.log(
|
|
449
|
-
`Instagram:
|
|
352
|
+
`Instagram: subindo o vídeo para a cloud, de onde o Instagram baixa (${(statSync(mp4).size / 1e6).toFixed(1)} MB)…`,
|
|
450
353
|
);
|
|
451
|
-
const link = await servePublic(cover ? [mp4, cover.jpg] : [mp4]);
|
|
354
|
+
const link = await servePublic(slug, cover ? [mp4, cover.jpg] : [mp4]);
|
|
452
355
|
const [videoUrl, coverUrl] = link.urls;
|
|
453
356
|
return publishReel({
|
|
454
357
|
client: igClient(token),
|
|
@@ -809,7 +712,7 @@ async function publish(dir) {
|
|
|
809
712
|
plat === "youtube"
|
|
810
713
|
? await uploadYoutube(exp.mp4, meta, privacy, video, cover)
|
|
811
714
|
: plat === "instagram"
|
|
812
|
-
? await uploadInstagram(exp.mp4, meta.caption, cover)
|
|
715
|
+
? await uploadInstagram(basename(dir), exp.mp4, meta.caption, cover)
|
|
813
716
|
: await uploadTiktok(exp.mp4, meta.tiktokCaption, video, tiktokPrivacy);
|
|
814
717
|
pub[plat] = publishedEntry({
|
|
815
718
|
result: r,
|
|
@@ -899,8 +802,8 @@ async function publishCarousel(videoDir) {
|
|
|
899
802
|
|
|
900
803
|
try {
|
|
901
804
|
const { token, id } = await instagramToken();
|
|
902
|
-
console.log(`Instagram:
|
|
903
|
-
const link = await servePublic(jpgs);
|
|
805
|
+
console.log(`Instagram: subindo os ${jpgs.length} slides para a cloud, de onde o Instagram baixa…`);
|
|
806
|
+
const link = await servePublic(basename(videoDir), jpgs);
|
|
904
807
|
const done = await publishCarouselPost({
|
|
905
808
|
client: igClient(token),
|
|
906
809
|
userId: id,
|
|
@@ -1361,7 +1264,7 @@ function runOne(dir) {
|
|
|
1361
1264
|
|
|
1362
1265
|
/* --- the launchd agent ---------------------------------------------------------------
|
|
1363
1266
|
Runs `scheduler run` every 60 s with the node that installed it. PATH goes along because
|
|
1364
|
-
launchd starts with a minimal PATH, without ffprobe
|
|
1267
|
+
launchd starts with a minimal PATH, without ffprobe or claude. */
|
|
1365
1268
|
const domain = () => `gui/${process.getuid()}`;
|
|
1366
1269
|
const launchctl = (...a) => spawnSync("launchctl", a, { encoding: "utf8" });
|
|
1367
1270
|
|
package/tools/reference.mjs
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
guia reproduz a peça, e não só a descreve.
|
|
35
35
|
|
|
36
36
|
Ritmo não se sente em quadro parado, mas se mede: é o que este script dá.
|
|
37
|
-
Requer ffmpeg/ffprobe; yt-dlp para baixar de link.
|
|
37
|
+
Requer ffmpeg/ffprobe; yt-dlp para baixar de link (o omni traz os três em ~/.omni/bin).
|
|
38
38
|
============================================================================ */
|
|
39
39
|
|
|
40
40
|
import {
|
package/tools/setup.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/* Prepares a fresh OmniVideo clone
|
|
3
|
-
the
|
|
4
|
-
|
|
5
|
-
(/getting-started
|
|
2
|
+
/* Prepares a fresh OmniVideo clone (the development repository) and checks
|
|
3
|
+
whether the machine is ready for the first video. A plugin install doesn't use
|
|
4
|
+
it: `omni up` prepares the computer, and the studio's Getting started page
|
|
5
|
+
(/getting-started, docs/onboarding.md) measures its own steps
|
|
6
|
+
(studio/api/services/onboarding.ts).
|
|
6
7
|
|
|
7
8
|
node tools/setup.mjs prepares what's missing and checks everything
|
|
8
9
|
node tools/setup.mjs check [--json] only checks (the UI reads --json)
|
|
@@ -89,7 +90,8 @@ const version = (cmd, a = ["--version"]) => {
|
|
|
89
90
|
};
|
|
90
91
|
|
|
91
92
|
/* --- check ------------------------------------------------------------------
|
|
92
|
-
|
|
93
|
+
The development repository's steps (machine, project, Claude, voice, style, sound…), printed in the
|
|
94
|
+
terminal; the Getting started page has its own.
|
|
93
95
|
state: ok · missing (blocks the first video) · warning (works, but take a look) */
|
|
94
96
|
function check() {
|
|
95
97
|
const c = cfg();
|
|
@@ -207,7 +209,7 @@ function check() {
|
|
|
207
209
|
ready.length ? "ok" : "missing",
|
|
208
210
|
ready.length
|
|
209
211
|
? `${ready.length} de ${accounts.length} conta(s) com login`
|
|
210
|
-
: `rode ${accounts[0] ? loginCommand(accounts[0], cfg) : "
|
|
212
|
+
: `rode ${accounts[0] ? loginCommand(accounts[0], cfg) : "claude auth login"}`,
|
|
211
213
|
),
|
|
212
214
|
...accounts
|
|
213
215
|
.filter(a => !loginOf(a) && a !== accounts[0])
|
|
@@ -328,17 +330,12 @@ function check() {
|
|
|
328
330
|
/* 8. publish (optional) */
|
|
329
331
|
{
|
|
330
332
|
const has = (...ks) => ks.every(k => env.has(k));
|
|
331
|
-
|
|
333
|
+
/* Instagram downloads the video from the cloud's R2 (publish.mjs servePublic) */
|
|
334
|
+
const cloud = !!(c.cloud?.url || c.omnichat?.cloud?.url) && env.has("OMNICHAT_CLOUD_SECRET");
|
|
332
335
|
steps.push({
|
|
333
336
|
id: "publish",
|
|
334
337
|
optional: true,
|
|
335
338
|
items: [
|
|
336
|
-
item(
|
|
337
|
-
"cloudflared",
|
|
338
|
-
"cloudflared (o Instagram busca o vídeo por ele)",
|
|
339
|
-
cloudflared ? "ok" : "warning",
|
|
340
|
-
cloudflared ? cloudflared.split(" (")[0] : "opcional: só para publicar no Instagram",
|
|
341
|
-
),
|
|
342
339
|
item(
|
|
343
340
|
"youtube",
|
|
344
341
|
"YouTube",
|
|
@@ -348,8 +345,12 @@ function check() {
|
|
|
348
345
|
item(
|
|
349
346
|
"instagram",
|
|
350
347
|
"Instagram",
|
|
351
|
-
has("INSTAGRAM_TOKEN", "INSTAGRAM_USER_ID") ? "ok" : "warning",
|
|
352
|
-
has("INSTAGRAM_TOKEN")
|
|
348
|
+
has("INSTAGRAM_TOKEN", "INSTAGRAM_USER_ID") && cloud ? "ok" : "warning",
|
|
349
|
+
!has("INSTAGRAM_TOKEN")
|
|
350
|
+
? "opcional: não conectado"
|
|
351
|
+
: cloud
|
|
352
|
+
? "conectado"
|
|
353
|
+
: "conectado, mas o Instagram baixa o vídeo da cloud: falta cloud.url no config ou OMNICHAT_CLOUD_SECRET no .env",
|
|
353
354
|
),
|
|
354
355
|
item(
|
|
355
356
|
"tiktok",
|
package/tools/sound.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
node tools/sound.mjs kit [role|text] lists the global kit by role
|
|
9
9
|
node tools/sound.mjs kit adopt videos/<slug> <name> --role <role> [--as <name-in-kit>]
|
|
10
10
|
moves an effect captured for the video into the kit
|
|
11
|
+
node tools/sound.mjs import videos/<slug> <file> [name] [--title <text>]
|
|
12
|
+
a track the user sent (the editor's upload), as an MP3 of the video's sfx/
|
|
11
13
|
node tools/sound.mjs effects videos/<slug> [text] lists the effects captured for the video
|
|
12
14
|
node tools/sound.mjs videos/<slug> prepares the video's sound/
|
|
13
15
|
|
|
@@ -16,7 +18,9 @@
|
|
|
16
18
|
by every video, each effect with a role (click, pop, tick, typing, confirm,
|
|
17
19
|
whoosh, transition, riser, impact, glitch, bed, texture); and the video's
|
|
18
20
|
sfx/ (sfx/sources.json), for what the kit lacks. No other audio saved on the
|
|
19
|
-
computer gets in
|
|
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
|
|
20
24
|
config), the `capture` code plays the previews and returns each one's URL
|
|
21
25
|
(audiocdn.epidemicsound.com/lqmp3/<id>.mp3); `download` writes the MP3 to
|
|
22
26
|
the kit or the video's sfx/ and records the origin. An effect outside both
|
|
@@ -79,7 +83,7 @@
|
|
|
79
83
|
queue turn (tools/job-queue.mjs).
|
|
80
84
|
============================================================================ */
|
|
81
85
|
|
|
82
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs";
|
|
86
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs";
|
|
83
87
|
import { spawnSync } from "node:child_process";
|
|
84
88
|
import { join, resolve, basename, relative } from "node:path";
|
|
85
89
|
import { fileURLToPath } from "node:url";
|
|
@@ -406,6 +410,62 @@ async function download(videoDir, url, name, search, role) {
|
|
|
406
410
|
);
|
|
407
411
|
}
|
|
408
412
|
|
|
413
|
+
/* A track the user sent from the editor (the upload): any audio file becomes an MP3 in the video's sfx/,
|
|
414
|
+
registered with the origin "upload". It is the user's own file, so it is theirs to license; it stays in
|
|
415
|
+
this video (adopt refuses it) and never reaches the kit. */
|
|
416
|
+
function importUpload(videoDir, file, wanted, title) {
|
|
417
|
+
const dir = resolve(videoDir ?? "");
|
|
418
|
+
if (!existsSync(join(dir, "scenes.json"))) throw new Error(`${dir} não é a pasta de um vídeo (falta scenes.json)`);
|
|
419
|
+
if (!file || !existsSync(file)) throw new Error(`o arquivo “${file}” não existe`);
|
|
420
|
+
const base = (wanted || basename(file)).replace(/\.[A-Za-z0-9]{1,5}$/, "");
|
|
421
|
+
const slug =
|
|
422
|
+
base
|
|
423
|
+
.normalize("NFD")
|
|
424
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
425
|
+
.toLowerCase()
|
|
426
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
427
|
+
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, "") || "musica";
|
|
428
|
+
const taken = n =>
|
|
429
|
+
!!sources(dir)[`${n}.mp3`] || !!kitSources()[`${n}.mp3`] || existsSync(join(effectsDir(dir), `${n}.mp3`));
|
|
430
|
+
let n = slug;
|
|
431
|
+
for (let i = 2; taken(n); i++) n = `${slug}-${i}`;
|
|
432
|
+
const name = `${n}.mp3`;
|
|
433
|
+
mkdirSync(effectsDir(dir), { recursive: true });
|
|
434
|
+
const out = join(effectsDir(dir), name);
|
|
435
|
+
/* an MP3 is kept as it is (without cover art or tags); anything else is encoded */
|
|
436
|
+
const isMp3 = /\.mp3$/i.test(file);
|
|
437
|
+
try {
|
|
438
|
+
run("ffmpeg", [
|
|
439
|
+
"-y",
|
|
440
|
+
"-v",
|
|
441
|
+
"error",
|
|
442
|
+
"-i",
|
|
443
|
+
file,
|
|
444
|
+
"-vn",
|
|
445
|
+
"-map_metadata",
|
|
446
|
+
"-1",
|
|
447
|
+
...(isMp3 ? ["-c:a", "copy"] : ["-c:a", "libmp3lame", "-q:a", "2"]),
|
|
448
|
+
out,
|
|
449
|
+
]);
|
|
450
|
+
const med = measureEffect(out);
|
|
451
|
+
if (!(med.duration > 0)) throw new Error("o arquivo não tem áudio");
|
|
452
|
+
} catch (e) {
|
|
453
|
+
rmSync(out, { force: true });
|
|
454
|
+
throw new Error(`não consegui ler “${basename(file)}” como áudio: ${String(e.message).slice(0, 200)}`);
|
|
455
|
+
}
|
|
456
|
+
cache.delete(out);
|
|
457
|
+
const list = sources(dir);
|
|
458
|
+
list[name] = {
|
|
459
|
+
source: "upload",
|
|
460
|
+
title: title || basename(file).replace(/\.[A-Za-z0-9]{1,5}$/, ""),
|
|
461
|
+
uploaded_at: new Date().toISOString().slice(0, 10),
|
|
462
|
+
};
|
|
463
|
+
writeFileSync(join(effectsDir(dir), "sources.json"), JSON.stringify(list, null, 1) + "\n");
|
|
464
|
+
const m = measureEffect(out);
|
|
465
|
+
console.log(`${name} ← ${list[name].title} · enviado pelo usuário`);
|
|
466
|
+
console.log(m.music ? ` ${m.duration.toFixed(0)}s · trilha (só música de fundo)` : ` ${describe(m)}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
409
469
|
const describe = m =>
|
|
410
470
|
`${m.duration.toFixed(2)}s · pico em ${m.peak_s}s (${m.peak_db} dB) · útil até ${m.useful_end_s}s · volume ${m.volume_lufs} LUFS`;
|
|
411
471
|
|
|
@@ -437,6 +497,8 @@ function adopt(videoDir, name, role, as) {
|
|
|
437
497
|
if (!ROLES[role]) throw new Error(`--role é obrigatório: ${Object.keys(ROLES).join(", ")}`);
|
|
438
498
|
const source = sources(dir)[name];
|
|
439
499
|
if (!source) throw new Error(`“${name}” não está em ${join(effectsDir(dir), "sources.json")}`);
|
|
500
|
+
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`);
|
|
440
502
|
const newName = `${(as ?? name).replace(/\.mp3$/i, "")}.mp3`;
|
|
441
503
|
if (!NAME.test(newName)) throw new Error(`nome “${newName}”: letras, números, ponto, hífen e sublinhado`);
|
|
442
504
|
const list = kitSources();
|
|
@@ -1295,9 +1357,11 @@ if (isMainModule(import.meta.url)) {
|
|
|
1295
1357
|
try {
|
|
1296
1358
|
const option = n => (i => (i < 0 ? undefined : rest.splice(i, 2)[1]))(rest.indexOf(`--${n}`));
|
|
1297
1359
|
const search = option("search"),
|
|
1298
|
-
role = option("role")
|
|
1360
|
+
role = option("role"),
|
|
1361
|
+
title = option("title");
|
|
1299
1362
|
if (cmd === "capture") capture(+(rest[0] ?? 8));
|
|
1300
1363
|
else if (cmd === "download") await download(rest[0], rest[1], rest[2], search, role);
|
|
1364
|
+
else if (cmd === "import") importUpload(rest[0], rest[1], rest[2], title);
|
|
1301
1365
|
else if (cmd === "effects") effects(rest[0], rest[1]);
|
|
1302
1366
|
else if (cmd === "kit" && rest[0] === "adopt") adopt(rest[1], rest[2], role, option("as") ?? rest[3]);
|
|
1303
1367
|
else if (cmd === "kit") kit(rest[0]);
|