@crievo/omni 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +2 -1
  2. package/reference/learnings.md +12 -0
  3. package/studio/api/app.ts +2 -2
  4. package/studio/api/routes/sound.ts +22 -3
  5. package/studio/api/services/sound.ts +45 -0
  6. package/studio/studio.ts +1 -0
  7. package/studio/web/components/AppShell.tsx +3 -1
  8. package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
  9. package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
  10. package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
  11. package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
  12. package/studio/web/components/Omni/OmniWidget.tsx +14 -5
  13. package/studio/web/components/Omni/PendingActions.tsx +25 -6
  14. package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
  15. package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
  16. package/studio/web/components/ResultMessage.tsx +12 -3
  17. package/studio/web/components/Sidebar.tsx +63 -24
  18. package/studio/web/components/StageStepper.tsx +105 -34
  19. package/studio/web/components/Toaster.tsx +25 -0
  20. package/studio/web/components/VoiceCarousel.tsx +19 -16
  21. package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
  22. package/studio/web/components/bencho/ActionNode.tsx +456 -0
  23. package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
  24. package/studio/web/components/bencho/BenchoScope.tsx +8 -0
  25. package/studio/web/components/bencho/Checklist.tsx +421 -0
  26. package/studio/web/components/bencho/CreateMenu.tsx +258 -0
  27. package/studio/web/components/bencho/GenerateImage.tsx +290 -0
  28. package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
  29. package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
  30. package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
  31. package/studio/web/components/bencho/NowPlaying.tsx +403 -0
  32. package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
  33. package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
  34. package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
  35. package/studio/web/components/bencho/SloshSlider.tsx +394 -0
  36. package/studio/web/components/bencho/StackedBars.tsx +342 -0
  37. package/studio/web/components/bencho/ToastStack.tsx +263 -0
  38. package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
  39. package/studio/web/components/bencho/VoiceNote.tsx +705 -0
  40. package/studio/web/components/icons.tsx +27 -0
  41. package/studio/web/components/ui/slider.tsx +34 -1
  42. package/studio/web/components/ui/switch.tsx +18 -5
  43. package/studio/web/index.html +1 -1
  44. package/studio/web/lib/omni-panel.ts +4 -1
  45. package/studio/web/lib/toast.ts +196 -0
  46. package/studio/web/lib/upload.ts +24 -0
  47. package/studio/web/pages/BenchoPage/index.tsx +74 -0
  48. package/studio/web/pages/BenchoPage/registry.ts +174 -0
  49. package/studio/web/pages/NewVideoPage/index.tsx +6 -2
  50. package/studio/web/pages/ReferencesPage/index.tsx +4 -1
  51. package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
  52. package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
  53. package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
  54. package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
  55. package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
  56. package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
  57. package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
  58. package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
  59. package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
  60. package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
  61. package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
  62. package/studio/web/pages/VideoPage/index.tsx +4 -0
  63. package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
  64. package/studio/web/router.tsx +8 -0
  65. package/studio/web/styles/components/bencho/action-node.css +227 -0
  66. package/studio/web/styles/components/bencho/checklist.css +182 -0
  67. package/studio/web/styles/components/bencho/dock.css +188 -0
  68. package/studio/web/styles/components/bencho/gallery.css +109 -0
  69. package/studio/web/styles/components/bencho/generate.css +151 -0
  70. package/studio/web/styles/components/bencho/image-accordion.css +37 -0
  71. package/studio/web/styles/components/bencho/liq-create.css +279 -0
  72. package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
  73. package/studio/web/styles/components/bencho/one-time-code.css +148 -0
  74. package/studio/web/styles/components/bencho/picker.css +204 -0
  75. package/studio/web/styles/components/bencho/pull.css +313 -0
  76. package/studio/web/styles/components/bencho/scope.css +42 -0
  77. package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
  78. package/studio/web/styles/components/bencho/slosh.css +137 -0
  79. package/studio/web/styles/components/bencho/sound.css +260 -0
  80. package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
  81. package/studio/web/styles/components/bencho/toast-stack.css +192 -0
  82. package/studio/web/styles/components/bencho/tokens.css +243 -0
  83. package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
  84. package/studio/web/styles/components/bencho/voice-note.css +337 -0
  85. package/studio/web/styles/components/clone-voice.css +12 -2
  86. package/studio/web/styles/components/omni.css +6 -1
  87. package/studio/web/styles/components/stepper.css +123 -7
  88. package/studio/web/styles/components/toaster.css +10 -0
  89. package/studio/web/styles/components/ui/slider.css +6 -16
  90. package/studio/web/styles/components/ui/switch.css +2 -41
  91. package/studio/web/styles/components/voice-carousel.css +46 -4
  92. package/studio/web/styles/index.css +23 -0
  93. package/studio/web/styles/pages/video/layout.css +1 -4
  94. package/studio/web/styles/pages/video/sound-timeline.css +8 -0
  95. package/studio/web/styles/theme/identity/shell.css +10 -26
  96. package/tools/omnichat.mjs +19 -6
  97. package/tools/sound.mjs +67 -3
@@ -114,3 +114,26 @@
114
114
  @import "./pages/agent.css";
115
115
  @import "./components/omni.css";
116
116
  @import "./components/omni-cards.css";
117
+
118
+ /* Bencho ports (the /bencho gallery): its own palette, the page, then one file per component */
119
+ @import "./components/bencho/tokens.css";
120
+ @import "./components/bencho/scope.css";
121
+ @import "./components/bencho/gallery.css";
122
+ @import "./components/bencho/pull.css";
123
+ @import "./components/bencho/liq-toggle.css";
124
+ @import "./components/bencho/checklist.css";
125
+ @import "./components/bencho/sound.css";
126
+ @import "./components/bencho/dock.css";
127
+ @import "./components/bencho/slosh.css";
128
+ @import "./components/bencho/liq-create.css";
129
+ @import "./components/bencho/picker.css";
130
+ @import "./components/bencho/slide-confirm.css";
131
+ @import "./components/bencho/image-accordion.css";
132
+ @import "./components/bencho/action-node.css";
133
+ @import "./components/bencho/generate.css";
134
+ @import "./components/bencho/one-time-code.css";
135
+ @import "./components/bencho/upload-dropzone.css";
136
+ @import "./components/bencho/voice-note.css";
137
+ @import "./components/bencho/toast-stack.css";
138
+ @import "./components/toaster.css";
139
+ @import "./components/bencho/stacked-bars.css";
@@ -41,10 +41,7 @@
41
41
  padding: 4px 24px;
42
42
  background: none;
43
43
  border-bottom: 1px solid var(--border);
44
- overflow-x: auto;
45
- }
46
- .rail .stepper {
47
- min-width: 900px;
44
+ /* no scroll box: the stage cards' fans hang below the rail and a scroller would clip them; the cards wrap instead */
48
45
  }
49
46
  /* sentinel at the rail's natural position (takes no space): when it leaves through the top, the rail
50
47
  is stuck and gets the panel background */
@@ -244,6 +244,9 @@
244
244
  position: absolute;
245
245
  left: 6px;
246
246
  top: 4px;
247
+ display: flex;
248
+ align-items: center;
249
+ gap: 6px;
247
250
  }
248
251
  .sound-header {
249
252
  position: absolute;
@@ -670,3 +673,8 @@
670
673
  border-top-color: transparent;
671
674
  animation: spin 0.8s linear infinite;
672
675
  }
676
+ .sound-add-buttons {
677
+ display: flex;
678
+ flex-wrap: wrap;
679
+ gap: 6px;
680
+ }
@@ -89,44 +89,28 @@ main.with-sidebar {
89
89
  background: var(--el-nav-active-bg);
90
90
  color: var(--el-nav-active-text);
91
91
  }
92
- /* the "Criar vídeo" + inside the Videos row */
92
+ /* the "Criar" pill (components/bencho/CreateMenu) inside the Conteúdos row: it sits over the link at the right edge, as a
93
+ sibling (a button inside <a> is not valid HTML), and its menu hangs from the pill's top right corner. Out of flow,
94
+ centred on the row by margin rather than a transform: a transformed box would be a stacking context of its own. */
93
95
  .menu .menu-item-with-action {
94
96
  position: relative;
95
97
  display: flex;
96
98
  flex-direction: column;
97
99
  }
98
100
  .menu .menu-item-with-action > a {
99
- padding-right: 36px;
101
+ padding-right: 100px;
100
102
  }
101
- .menu .menu-action {
103
+ .menu .menu-create {
102
104
  position: absolute;
103
105
  top: 50%;
104
- right: 4px;
105
- transform: translateY(-50%);
106
- display: inline-flex;
107
- align-items: center;
108
- gap: 4px;
109
- height: 24px;
110
- padding: 0 8px 0 6px;
111
- font: 500 12px / 1 var(--sans);
112
- border: 0;
113
- border-radius: var(--el-radius-nav);
114
- background: transparent;
115
- color: var(--text-2);
116
- cursor: pointer;
117
- }
118
- .menu .menu-action svg {
119
- width: 12px;
120
- height: 12px;
121
- }
122
- .menu .menu-action:hover,
123
- .menu .menu-action:focus-visible {
124
- background: var(--surface-2);
125
- color: var(--text);
106
+ right: 3px;
107
+ margin-top: calc(var(--pill-h) / -2);
126
108
  }
127
109
  @media (max-width: 900px) {
128
110
  .menu {
129
111
  width: auto;
112
+ /* the bar has no fixed height to scroll in, and the create menu hangs below it */
113
+ overflow: visible;
130
114
  }
131
115
  .brand {
132
116
  margin-bottom: 0;
@@ -213,7 +197,7 @@ main.with-sidebar {
213
197
  }
214
198
  .menu-collapsed .brand-omni,
215
199
  .menu-collapsed .menu-label,
216
- .menu-collapsed .menu-action {
200
+ .menu-collapsed .menu-create {
217
201
  display: none;
218
202
  }
219
203
  .menu-collapsed .menu-group-name {
@@ -1033,10 +1033,16 @@ async function status() {
1033
1033
  const pause = readJson(PAUSE_FILE);
1034
1034
  if (isPaused(pause, Date.now())) s.pause = pause;
1035
1035
  if (cloudOwns()) {
1036
- await Promise.all(videosWithRule().map(d => pullRecord(d).catch(() => {})));
1037
- /* the cloud answers: its pause and its token are the ones that count */
1036
+ /* the cloud answers: its pause and its token are the ones that count; its posts' updated_at say
1037
+ which records changed since the mirror, and only those are pulled */
1038
1038
  try {
1039
1039
  const c = await cloudApi("GET", "/api/status");
1040
+ const at = new Map((c.posts ?? []).map(p => [String(p.media_id), p.updated_at]));
1041
+ await Promise.all(
1042
+ videosWithRule()
1043
+ .filter(d => at.has(String(mediaOf(d))))
1044
+ .map(d => pullRecord(d, at.get(String(mediaOf(d)))).catch(() => {})),
1045
+ );
1040
1046
  s.cloud = { ready: true, active: !!c.active, expires: c.account?.expires ?? null };
1041
1047
  if (!c.active) s.cloud.reason = "a cloud não está respondendo (cloud push)";
1042
1048
  if (c.account?.paused_until && Date.parse(c.account.paused_until) > Date.now())
@@ -1167,19 +1173,26 @@ function syncRule(dir) {
1167
1173
  }
1168
1174
  const flushCloud = () => Promise.all(pendingCloud.splice(0));
1169
1175
 
1170
- /* the cloud's record mirrored into omnichat-record.json: the studio and the log read the file */
1171
- async function pullRecord(dir) {
1176
+ /* The cloud's record mirrored into omnichat-record.json: the studio and the log read the file.
1177
+ `cloudAt`: the post's updated_at from /api/status; when the mirror already has that version, nothing is
1178
+ asked (the studio's status runs every few minutes, and each record that leaves Neon counts against the
1179
+ free plan's monthly transfer). */
1180
+ async function pullRecord(dir, cloudAt = null) {
1172
1181
  if (!cloudOwns()) return;
1173
1182
  const media = mediaOf(dir);
1174
1183
  if (!media) return;
1184
+ const file = recordFile(dir);
1185
+ if (cloudAt && readJson(file)?.cloud_updated_at === cloudAt) return;
1175
1186
  try {
1176
1187
  const out = await cloudApi("GET", `/api/posts/${media}`);
1177
- const file = recordFile(dir);
1178
1188
  const all = readJson(file) ?? {};
1179
1189
  /* only a change is written: the write is a studio notice, and the notice makes the page read (and
1180
1190
  pull) again, so an unchanged record written anyway loops against the cloud */
1181
- if (JSON.stringify(all.instagram ?? null) === JSON.stringify(out.record ?? null)) return;
1191
+ const at = out.updated_at ?? null;
1192
+ if (JSON.stringify(all.instagram ?? null) === JSON.stringify(out.record ?? null) && all.cloud_updated_at === at)
1193
+ return;
1182
1194
  all.instagram = out.record;
1195
+ all.cloud_updated_at = at;
1183
1196
  delete all.cloud;
1184
1197
  writeJson(file, all);
1185
1198
  } catch (e) {
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. With a search open in Chrome (sound.source.search in 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
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]);