@crievo/omni 0.1.3 → 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.
Files changed (96) 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/sound.mjs +67 -3
@@ -3,6 +3,7 @@
3
3
  which becomes an agent session (measure and write the guide). */
4
4
 
5
5
  import { useEffect, useState } from "react";
6
+ import { useLocation } from "@tanstack/react-router";
6
7
  import { useReferences } from "../../hooks/useReferences";
7
8
  import { Plus } from "../../components/icons";
8
9
  import { NewReferenceForm } from "./components/NewReferenceForm";
@@ -11,7 +12,9 @@ import { ReferenceGridSkeleton } from "./components/ReferenceGridSkeleton";
11
12
 
12
13
  export function ReferencesPage() {
13
14
  const { references, error } = useReferences();
14
- const [creating, setCreating] = useState(false);
15
+ /* the sidebar's Criar menu comes here with the new-style form already open (router history state, typed in Sidebar.tsx) */
16
+ const asked = useLocation({ select: l => l.state.creating === true });
17
+ const [creating, setCreating] = useState(asked);
15
18
  useEffect(() => {
16
19
  document.title = "Estilos visuais · Omni";
17
20
  }, []);
@@ -5,6 +5,7 @@ import { useState } from "react";
5
5
  import { useVoiceRunning } from "../../../hooks/useVoiceRunning";
6
6
  import { api, enc, send, type ActionResult } from "../../../lib/api";
7
7
  import { ChatCircleText } from "../../../components/icons";
8
+ import { toast, toastResult } from "../../../lib/toast";
8
9
  import type { VideoDetail } from "../../../types/api";
9
10
  import { scenesOf } from "./StageResult";
10
11
 
@@ -18,19 +19,20 @@ export function ChangedVoices({ video }: { video: VideoDetail }) {
18
19
  if (video.status?.stages.find(e => e.id === "voice")?.pause) return null;
19
20
  if (!changed.length || running || !video.status?.stages.find(e => e.id === "timing")?.ok) return null;
20
21
  const ask = async () => {
21
- setResponse(
22
- await send(
23
- api.videos[":slug"].claude.$post({
24
- param: { slug: enc(video.slug) },
25
- json: {
26
- message:
27
- `Troquei o texto ou a leitura das falas ${changed.join(", ")} pelo studio (texto no scenes.json, omnivideo.py voice --regenerate/--use). ` +
28
- "O texto e as leituras escolhidas ficam: não reescreva essas falas nem gere voz de novo para elas. Refaça o vídeo a partir do tempo, como na linha " +
29
- "“texto de uma fala” da tabela Retomar ou refazer da skill omnivideo: tempo, render das cenas afetadas, legenda e montagem.",
30
- },
31
- }),
32
- ),
22
+ const r = await send(
23
+ api.videos[":slug"].claude.$post({
24
+ param: { slug: enc(video.slug) },
25
+ json: {
26
+ message:
27
+ `Troquei o texto ou a leitura das falas ${changed.join(", ")} pelo studio (texto no scenes.json, omnivideo.py voice --regenerate/--use). ` +
28
+ "O texto e as leituras escolhidas ficam: não reescreva essas falas nem gere voz de novo para elas. Refaça o vídeo a partir do tempo, como na linha " +
29
+ "“texto de uma fala” da tabela Retomar ou refazer da skill omnivideo: tempo, render das cenas afetadas, legenda e montagem.",
30
+ },
31
+ }),
33
32
  );
33
+ setResponse(r);
34
+ if (r.ok) toast.success("Pedido enviado", { body: "Acompanhe na sessão acima." });
35
+ else toastResult(r);
34
36
  };
35
37
  return (
36
38
  <div className="voices-notice">
@@ -42,11 +44,6 @@ export function ChangedVoices({ video }: { video: VideoDetail }) {
42
44
  <button className="button primary small" onClick={ask} disabled={!!response?.ok}>
43
45
  <ChatCircleText className="ic" /> Pedir ao agente para refazer
44
46
  </button>
45
- {response && (
46
- <span className={`response ${response.ok ? "ok" : "error"}`}>
47
- {response.ok ? "Pedido enviado: acompanhe na sessão acima" : response.error}
48
- </span>
49
- )}
50
47
  </div>
51
48
  </div>
52
49
  );
@@ -118,7 +118,7 @@ export function LineTextEditor({ video, line, only }: { video: VideoDetail; line
118
118
  Se o texto tirar a palavra em que um beat entra, o agente grava a fala e muda o beat junto. Escreva como se
119
119
  fala: número por extenso quando a voz pode errar.
120
120
  </p>
121
- {response && !response.ok && <p className="response error">{response.error}</p>}
121
+ <ResultMessage result={response} />
122
122
  </div>
123
123
  );
124
124
  }
@@ -7,9 +7,12 @@ import { api, enc, send as sendRequest, type ActionResult } from "../../../lib/a
7
7
  import { formatDayTime, timeAgo } from "../../../lib/format";
8
8
  import { PLATFORMS, formatPlatformList, type PlatformId } from "../../../lib/platforms";
9
9
  import { deletePublishDraft, readPublishDraft, savePublishDraft } from "../../../lib/publish-draft";
10
+ import { toastResult } from "../../../lib/toast";
10
11
  import { PlatformCard } from "../../../components/PlatformCard";
11
12
  import { ResultMessage } from "../../../components/ResultMessage";
12
- import { CalendarBlank, ChatsCircle, Check, UploadSimple, X } from "../../../components/icons";
13
+ import { BenchoScope } from "../../../components/bencho/BenchoScope";
14
+ import { SlideToConfirm } from "../../../components/bencho/SlideToConfirm";
15
+ import { CalendarBlank, ChatsCircle, UploadSimple, X } from "../../../components/icons";
13
16
  import type { FileEntry, VideoDetail } from "../../../types/api";
14
17
  import { ScheduledInfo } from "./ScheduledInfo";
15
18
  import { CoverPicker } from "./CoverPicker";
@@ -32,7 +35,6 @@ export function PublishForm({ video, current }: { video: VideoDetail; current: F
32
35
  },
33
36
  );
34
37
  const [writing, setWriting] = useState(false);
35
- const [textNotice, setTextNotice] = useState<ActionResult>(null);
36
38
  const [privacy, setPrivacy] = useState<string | null>(draft?.privacy ?? null);
37
39
  const [tiktokPrivacy, setTiktokPrivacy] = useState<string | null>(draft?.privacyTiktok ?? null);
38
40
  const [checked, setChecked] = useState<Record<string, boolean>>(draft?.checked ?? {});
@@ -95,12 +97,14 @@ export function PublishForm({ video, current }: { video: VideoDetail; current: F
95
97
  /* `publish.mjs text`: Claude writes from the script; redo replaces what is in the fields */
96
98
  const write = async (redo: boolean) => {
97
99
  setWriting(true);
98
- setTextNotice(null);
99
100
  const r = await sendRequest(
100
101
  api.videos[":slug"].publish.text.$post({ param: { slug: enc(video.slug) }, json: { redo } }),
101
102
  );
102
103
  setWriting(false);
103
- setTextNotice(r);
104
+ /* a failure, or Claude answering without writing: a toast (the fields keep what they had) */
105
+ if (!r.ok || /não escreveu/.test(r.output ?? "")) {
106
+ toastResult({ ok: false, error: r.ok ? r.output : r.error }, undefined, { always: true });
107
+ }
104
108
  /* the text publish.mjs wrote: a field it didn't write keeps what is there */
105
109
  const t = r.ok && r.text && typeof r.text === "object" && !Array.isArray(r.text) ? r.text : null;
106
110
  if (t) {
@@ -255,9 +259,6 @@ export function PublishForm({ video, current }: { video: VideoDetail; current: F
255
259
  })}
256
260
  </div>
257
261
 
258
- {textNotice && (!textNotice.ok || /não escreveu/.test(textNotice.output ?? "")) && (
259
- <p className="response error">{textNotice.ok ? textNotice.output : textNotice.error}</p>
260
- )}
261
262
  <PublishText
262
263
  text={text}
263
264
  onField={field}
@@ -350,16 +351,22 @@ export function PublishForm({ video, current }: { video: VideoDetail; current: F
350
351
  </button>
351
352
  ) : (
352
353
  <>
353
- <button className="button approve" disabled={sending} onClick={send}>
354
- <Check className="ic" />
355
- {sending
356
- ? scheduling
357
- ? "Agendando…"
358
- : "Publicando…"
359
- : scheduling
360
- ? `Confirmar: publicar ${formatPlatformList(chosen)} ${formatDayTime(whenMs)}`
361
- : `Confirmar: publicar ${formatPlatformList(chosen)}`}
362
- </button>
354
+ <p className="hint" style={{ flexBasis: "100%", margin: 0 }}>
355
+ <strong>
356
+ {scheduling ? "Agendar" : "Publicar"} {formatPlatformList(chosen)}
357
+ {scheduling ? ` ${formatDayTime(whenMs)}` : ""}.
358
+ </strong>
359
+ </p>
360
+ <BenchoScope>
361
+ <SlideToConfirm
362
+ className="bn-sld-grow"
363
+ width="fill"
364
+ label={scheduling ? "Deslize para agendar" : "Deslize para publicar"}
365
+ busyLabel={scheduling ? "Agendando…" : "Publicando…"}
366
+ busy={sending}
367
+ onConfirm={send}
368
+ />
369
+ </BenchoScope>
363
370
  {!sending && (
364
371
  <button className="button" onClick={() => setConfirm(false)}>
365
372
  <X className="ic" /> Cancelar
@@ -6,6 +6,7 @@ import { api, enc, send, type ActionResult } from "../../../lib/api";
6
6
  import { formatDayTime } from "../../../lib/format";
7
7
  import { formatPlatformList } from "../../../lib/platforms";
8
8
  import { Clock } from "../../../components/icons";
9
+ import { ResultMessage } from "../../../components/ResultMessage";
9
10
  import type { PlatformAccounts, ScheduledPost, VideoDetail } from "../../../types/api";
10
11
 
11
12
  const TITLE: Record<ScheduledPost["state"], string> = {
@@ -66,7 +67,7 @@ export function ScheduledInfo({
66
67
  {cancelling ? "Cancelando…" : "Cancelar agendamento"}
67
68
  </button>
68
69
  )}
69
- {response && !response.ok && <p className="response error">{response.error}</p>}
70
+ <ResultMessage result={response} />
70
71
  </div>
71
72
  );
72
73
  }
@@ -4,6 +4,8 @@ import { useState } from "react";
4
4
  import { api, enc, send as sendRequest } from "../../../../lib/api";
5
5
  import { Plus } from "../../../../components/icons";
6
6
  import { useOmniHandle } from "../../../../lib/omni-ui";
7
+ import { toast } from "../../../../lib/toast";
8
+ import { UploadTrack } from "./UploadTrack";
7
9
 
8
10
  /* adds an Epidemic effect: the URL captured in the player becomes an MP3 in the video's sfx/ */
9
11
  export function AddSound({ slug, onAdd }: { slug: string; onAdd: (name: string | null) => void }) {
@@ -11,7 +13,6 @@ export function AddSound({ slug, onAdd }: { slug: string; onAdd: (name: string |
11
13
  const [epidemicUrl, setEpidemicUrl] = useState("");
12
14
  const [name, setName] = useState("");
13
15
  const [downloading, setDownloading] = useState(false);
14
- const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
15
16
  /* a direct MP3 (audiocdn…/lqmp3/<id>.mp3) downloads at once; any other Epidemic page (playlist,
16
17
  theme, search) goes to the agent, which opens it in Chrome, plays and captures the MP3 */
17
18
  const pageUrl = (u: string) => !/audiocdn\.epidemicsound\.com\/lqmp3\//.test(u);
@@ -21,7 +22,6 @@ export function AddSound({ slug, onAdd }: { slug: string; onAdd: (name: string |
21
22
  if (!url.trim() || downloading) return { ok: false, error: downloading ? "já está baixando" : "falta o link" };
22
23
  const isPage = pageUrl(url);
23
24
  setDownloading(true);
24
- setMsg(null);
25
25
  const param = { slug: enc(slug) };
26
26
  const json = { url: url.trim(), name: file.trim() };
27
27
  const r = await sendRequest(
@@ -31,20 +31,17 @@ export function AddSound({ slug, onAdd }: { slug: string; onAdd: (name: string |
31
31
  );
32
32
  setDownloading(false);
33
33
  if (!r.ok) {
34
- setMsg({ ok: false, text: r.error ?? "Não consegui baixar" });
34
+ toast.error("Não consegui baixar o som", { body: r.error });
35
35
  return { ok: false, error: r.error ?? "não consegui baixar" };
36
36
  }
37
37
  if (isPage) {
38
38
  /* the agent works in the background; the timeline reloads when it stops */
39
- setMsg({
40
- ok: true,
41
- text: "O agente abriu a página no Chrome e vai baixar a faixa. A lista atualiza quando ele terminar.",
42
- });
39
+ toast.info("O agente abriu a página no Chrome", { body: "Ele baixa a faixa; a lista atualiza quando terminar." });
43
40
  setEpidemicUrl("");
44
41
  setName("");
45
42
  return { ok: true, output: "o agente do editor abriu a página no Chrome e vai baixar a faixa" };
46
43
  }
47
- setMsg({ ok: true, text: r.output ?? "Adicionado" });
44
+ toast.success("Som adicionado", { body: r.output?.split(" ←")[0] });
48
45
  setEpidemicUrl("");
49
46
  setName("");
50
47
  onAdd(r.output?.split(" ←")[0] ?? null);
@@ -55,15 +52,18 @@ export function AddSound({ slug, onAdd }: { slug: string; onAdd: (name: string |
55
52
  setOpen(true);
56
53
  setEpidemicUrl(url);
57
54
  setName(file);
58
- setMsg(null);
59
55
  },
60
56
  submit: (url: string, file: string) => send(url, file),
61
57
  });
62
58
  if (!open)
63
59
  return (
64
- <button className="button small" onClick={() => setOpen(true)}>
65
- <Plus className="ic" /> Adicionar som
66
- </button>
60
+ <div className="sound-add-buttons">
61
+ <button className="button small" onClick={() => setOpen(true)}>
62
+ <Plus className="ic" /> Adicionar som
63
+ </button>
64
+ {/* the user's own file: it joins this video's sounds, armed to place on the timeline */}
65
+ <UploadTrack slug={slug} label="Enviar áudio" onUploaded={onAdd} />
66
+ </div>
67
67
  );
68
68
  return (
69
69
  <div className="sound-add">
@@ -100,7 +100,6 @@ export function AddSound({ slug, onAdd }: { slug: string; onAdd: (name: string |
100
100
  className="button small"
101
101
  onClick={() => {
102
102
  setOpen(false);
103
- setMsg(null);
104
103
  }}
105
104
  >
106
105
  Fechar
@@ -111,7 +110,6 @@ export function AddSound({ slug, onAdd }: { slug: string; onAdd: (name: string |
111
110
  dá play e pega a primeira faixa que o vídeo ainda não tem. Ou cole direto o MP3 do player
112
111
  (audiocdn…/lqmp3/….mp3). O arquivo vai para o sfx/ deste vídeo, com a origem registrada.
113
112
  </p>
114
- {msg && <p className={`response ${msg.ok ? "ok" : "error"}`}>{msg.text}</p>}
115
113
  </div>
116
114
  );
117
115
  }
@@ -3,11 +3,12 @@
3
3
 
4
4
  import { defaultBedName } from "../../../../lib/sound-timeline";
5
5
  import type { TimelineGeometry } from "./timelineGeometry";
6
+ import { UploadTrack } from "./UploadTrack";
6
7
  import type { SoundTimelineState } from "./useSoundTimeline";
7
8
  import type { SoundData } from "./types";
8
9
 
9
10
  export function BedRow({ st, geo, data }: { st: SoundTimelineState; geo: TimelineGeometry; data: SoundData }) {
10
- const { plan, sel, setSel, dropTarget, changeDraft } = st;
11
+ const { plan, sel, setSel, dropTarget, changeDraft, reloadCatalog } = st;
11
12
  const { x } = geo;
12
13
  return (
13
14
  <div
@@ -25,20 +26,35 @@ export function BedRow({ st, geo, data }: { st: SoundTimelineState; geo: Timelin
25
26
  </span>
26
27
  </div>
27
28
  ) : (
28
- <button
29
- className="mini-button sound-no-bed"
30
- onClick={() => {
31
- const name = defaultBedName(data.catalog);
32
- if (name) {
29
+ <div className="sound-no-bed">
30
+ <button
31
+ className="mini-button"
32
+ onClick={() => {
33
+ const name = defaultBedName(data.catalog);
34
+ if (name) {
35
+ changeDraft(r => {
36
+ r.bed = { name };
37
+ });
38
+ setSel("bed");
39
+ }
40
+ }}
41
+ >
42
+ + música de fundo
43
+ </button>
44
+ {/* the user's own file: it joins this video's sounds and becomes the background music */}
45
+ <UploadTrack
46
+ slug={st.slug}
47
+ className="mini-button"
48
+ label="Enviar música"
49
+ onUploaded={name => {
50
+ reloadCatalog(null);
33
51
  changeDraft(r => {
34
52
  r.bed = { name };
35
53
  });
36
54
  setSel("bed");
37
- }
38
- }}
39
- >
40
- + música de fundo
41
- </button>
55
+ }}
56
+ />
57
+ </div>
42
58
  )}
43
59
  </div>
44
60
  );
@@ -1,32 +1,36 @@
1
1
  /* Floats at the bottom right of the Legenda box (it sticks as the box scrolls) once the caption has an unsaved change: Salvar legenda saves the
2
2
  position and renders the export again; Descartar goes back to what is saved. */
3
3
 
4
+ import { useEffect } from "react";
4
5
  import { ArrowCounterClockwise, FloppyDisk } from "../../../../components/icons";
5
6
  import { clone } from "../../../../lib/caption-position";
7
+ import { toastResult } from "../../../../lib/toast";
6
8
  import type { CaptionEditor } from "../CaptionPositionEditor/captionEditor";
7
9
 
8
10
  export function CaptionSaveBar({ editor, busy }: { editor: CaptionEditor | null; busy: boolean }) {
11
+ const message = editor?.message;
12
+ /* a failed save is a toast: the bar itself only exists while there is an edit to save or discard */
13
+ useEffect(() => {
14
+ if (message && !message.ok) toastResult({ ok: false, error: message.text }, undefined, { always: true });
15
+ }, [message]);
9
16
  if (!editor) return null;
10
- const { changed, saving, save, setDraft, data, message } = editor;
11
- if (!changed && !(message && !message.ok)) return null;
17
+ const { changed, saving, save, setDraft, data } = editor;
18
+ if (!changed) return null;
12
19
  return (
13
20
  <div className="caption-save-bar" role="region" aria-label="Salvar legenda">
14
- {message && !message.ok && <p className="response error">{message.text}</p>}
15
- {changed && (
16
- <div className="actions">
17
- <button className="button" disabled={saving} onClick={() => setDraft(clone(data.position))}>
18
- <ArrowCounterClockwise className="ic" /> Descartar
19
- </button>
20
- <button
21
- className="button primary"
22
- disabled={saving || busy}
23
- onClick={save}
24
- title="Salva a posição e o estilo e renderiza o export de novo"
25
- >
26
- <FloppyDisk className="ic" /> {saving ? "Salvando…" : "Salvar legenda"}
27
- </button>
28
- </div>
29
- )}
21
+ <div className="actions">
22
+ <button className="button" disabled={saving} onClick={() => setDraft(clone(data.position))}>
23
+ <ArrowCounterClockwise className="ic" /> Descartar
24
+ </button>
25
+ <button
26
+ className="button primary"
27
+ disabled={saving || busy}
28
+ onClick={save}
29
+ title="Salva a posição e o estilo e renderiza o export de novo"
30
+ >
31
+ <FloppyDisk className="ic" /> {saving ? "Salvando…" : "Salvar legenda"}
32
+ </button>
33
+ </div>
30
34
  </div>
31
35
  );
32
36
  }
@@ -1,5 +1,6 @@
1
- /* Under the timeline: the save status (autosave 5 s after the last edit) and the last action's response. */
1
+ /* Under the timeline: the save status (autosave 5 s after the last edit) and the last action's response, which is a toast. */
2
2
 
3
+ import { ResultMessage } from "../../../../components/ResultMessage";
3
4
  import type { SoundTimelineState } from "./useSoundTimeline";
4
5
 
5
6
  export function TimelineFooter({ st }: { st: SoundTimelineState }) {
@@ -18,9 +19,7 @@ export function TimelineFooter({ st }: { st: SoundTimelineState }) {
18
19
  : "Tudo salvo"}
19
20
  {!dirty && !saving && pending && !rendering && " · o export ainda não tem esse som"}
20
21
  </span>
21
- {response && (
22
- <p className={`response ${response.ok ? "ok" : "error"}`}>{response.ok ? response.output : response.error}</p>
23
- )}
22
+ <ResultMessage result={response} />
24
23
  </div>
25
24
  );
26
25
  }
@@ -0,0 +1,59 @@
1
+ /* "Enviar música": the user's own audio file becomes an MP3 in the video's sfx/ (sound.mjs import). The
2
+ button opens the file picker and reports the name the track got in a toast; what to do with it (arm it in the
3
+ library, make it the background music) is up to the caller. */
4
+
5
+ import { useRef, useState } from "react";
6
+ import { Plus } from "../../../../components/icons";
7
+ import { toast } from "../../../../lib/toast";
8
+ import { uploadTrackFile } from "../../../../lib/upload";
9
+
10
+ export function UploadTrack({
11
+ slug,
12
+ onUploaded,
13
+ label = "Enviar música",
14
+ className = "button small",
15
+ }: {
16
+ slug: string;
17
+ onUploaded: (name: string) => void;
18
+ label?: string;
19
+ className?: string;
20
+ }) {
21
+ const input = useRef<HTMLInputElement>(null);
22
+ const [pct, setPct] = useState<number | null>(null);
23
+ const pick = async (file: File | undefined) => {
24
+ if (!file) return;
25
+ setPct(0);
26
+ const r = await uploadTrackFile(slug, file, setPct);
27
+ setPct(null);
28
+ const name = r.output?.split(" ←")[0];
29
+ if (!r.ok || !name) {
30
+ toast.error("Não consegui enviar o arquivo", { body: r.error });
31
+ return;
32
+ }
33
+ toast.success("Áudio enviado", { body: name });
34
+ onUploaded(name);
35
+ };
36
+ return (
37
+ <>
38
+ <button
39
+ type="button"
40
+ className={className}
41
+ disabled={pct != null}
42
+ onClick={() => input.current?.click()}
43
+ title="Enviar um arquivo de áudio seu (mp3, wav, m4a…) para este vídeo"
44
+ >
45
+ <Plus className="ic" /> {pct == null ? label : pct < 100 ? `Enviando… ${pct}%` : "Convertendo…"}
46
+ </button>
47
+ <input
48
+ ref={input}
49
+ type="file"
50
+ accept="audio/*,.mp3,.wav,.m4a,.aac,.flac,.ogg,.opus,.aif,.aiff"
51
+ hidden
52
+ onChange={e => {
53
+ void pick(e.target.files?.[0]);
54
+ e.target.value = "";
55
+ }}
56
+ />
57
+ </>
58
+ );
59
+ }
@@ -478,6 +478,7 @@ export function useSoundTimeline(video: VideoDetail) {
478
478
  sceneVideos,
479
479
  seekPictureRef,
480
480
  reloadCatalog,
481
+ slug,
481
482
  stop,
482
483
  play,
483
484
  seek,
@@ -6,6 +6,7 @@ import { useVoiceRunning } from "../../../hooks/useVoiceRunning";
6
6
  import { api, enc, send, type ActionResult } from "../../../lib/api";
7
7
  import { TOOL_LABEL } from "../../../lib/format";
8
8
  import { ArrowRight } from "../../../components/icons";
9
+ import { toastResult } from "../../../lib/toast";
9
10
  import type { VideoDetail } from "../../../types/api";
10
11
 
11
12
  export function VoicePauseBanner({ video }: { video: VideoDetail }) {
@@ -32,8 +33,10 @@ export function VoicePauseBanner({ video }: { video: VideoDetail }) {
32
33
  ];
33
34
  const proceed = async () => {
34
35
  setSending(true);
35
- setResponse(await send(api.videos[":slug"].voice.proceed.$post({ param: { slug: enc(video.slug) }, json: {} })));
36
+ const r = await send(api.videos[":slug"].voice.proceed.$post({ param: { slug: enc(video.slug) }, json: {} }));
37
+ setResponse(r);
36
38
  setSending(false);
39
+ toastResult(r, undefined, { always: true });
37
40
  };
38
41
  return (
39
42
  <div className="voices-notice">
@@ -45,11 +48,6 @@ export function VoicePauseBanner({ video }: { video: VideoDetail }) {
45
48
  <button className="button primary small" onClick={proceed} disabled={sending || !!running || !!response?.ok}>
46
49
  <ArrowRight className="ic" /> {running ? "Esperando a voz terminar…" : "Seguir com estas vozes"}
47
50
  </button>
48
- {response && (
49
- <span className={`response ${response.ok ? "ok" : "error"}`}>
50
- {response.ok ? response.output : response.error}
51
- </span>
52
- )}
53
51
  </div>
54
52
  </div>
55
53
  );
@@ -236,6 +236,10 @@ export function VideoPage({ slug, stageParam }: { slug: string; stageParam: stri
236
236
  runs={video.runs}
237
237
  selected={stage.id}
238
238
  onSelect={choose}
239
+ onProcess={id => {
240
+ choose(id);
241
+ setPanel(true);
242
+ }}
239
243
  stages={pipeline === "video" ? videoStages : PIPELINES[pipeline]}
240
244
  />
241
245
  </nav>
@@ -4,6 +4,7 @@ import { api, enc, send } from "../../../lib/api";
4
4
  import { formatSeconds, timeAgo } from "../../../lib/format";
5
5
  import { aspectRatio, mediaUrl } from "../../../lib/media";
6
6
  import { SESSION_STATE } from "../../../lib/sessions";
7
+ import { toast } from "../../../lib/toast";
7
8
  import { activeSchedule, livePlatforms, carouselLive, contentReady } from "../../../lib/video-tabs";
8
9
  import { PIPELINES, PIPELINE_NAME, videoStagesFor } from "../../../lib/stages";
9
10
  import { useSessionFor } from "../../../hooks/useSessions";
@@ -26,7 +27,6 @@ export function VideoRow({ video, queue }: { video: VideoSummary; queue: Queue |
26
27
  const hasCarousel = video.deliverables.includes("carousel");
27
28
  const warnings = [...(hasVideo ? (st?.warnings ?? []) : []), ...(video.carousel?.warnings ?? [])];
28
29
  const [busy, setBusy] = useState(false);
29
- const [archiveError, setArchiveError] = useState<string | null>(null);
30
30
  /* the scheduler publishes with the studio closed, and a running task doesn't vanish midway: the server refuses too */
31
31
  const blocked = video.archived
32
32
  ? null
@@ -37,11 +37,10 @@ export function VideoRow({ video, queue }: { video: VideoSummary; queue: Queue |
37
37
  : null;
38
38
  const toggleArchive = async () => {
39
39
  setBusy(true);
40
- setArchiveError(null);
41
40
  const r = await send(
42
41
  api.videos[":slug"].archive.$post({ param: { slug: enc(video.slug) }, json: { archive: !video.archived } }),
43
42
  );
44
- if (!r.ok) setArchiveError(r.error ?? "Não deu para arquivar");
43
+ if (!r.ok) toast.error("Não deu para arquivar", { body: r.error });
45
44
  setBusy(false);
46
45
  };
47
46
  const needsAttention = video.scenes_approval?.status === "pending" || video.script_approval?.status === "pending";
@@ -88,7 +87,6 @@ export function VideoRow({ video, queue }: { video: VideoSummary; queue: Queue |
88
87
  {video.archived ? "Desarquivar" : "Arquivar"}
89
88
  </button>
90
89
  </div>
91
- {archiveError && <p className="response error">{archiveError}</p>}
92
90
  <PublishedOn video={video} />
93
91
  {hasVideo && st && !live && (
94
92
  <div className="vr-pipeline">
@@ -21,6 +21,7 @@ import { SchedulePage } from "./pages/SchedulePage";
21
21
  import { OmnichatPage, OmnichatRulePage } from "./pages/OmnichatPage";
22
22
  import { GettingStartedPage } from "./pages/GettingStartedPage";
23
23
  import { NotFoundPage } from "./pages/NotFoundPage";
24
+ import { BenchoPage } from "./pages/BenchoPage";
24
25
 
25
26
  export const rootRoute = createRootRoute({
26
27
  component: AppShell,
@@ -168,6 +169,12 @@ export const gettingStartedRoute = createRoute({
168
169
  path: "/getting-started",
169
170
  component: () => <GettingStartedPage />,
170
171
  });
172
+ /* the Bencho ports, side by side; not in the menu */
173
+ export const benchoRoute = createRoute({
174
+ getParentRoute: () => rootRoute,
175
+ path: "/bencho",
176
+ component: () => <BenchoPage />,
177
+ });
171
178
 
172
179
  export const routeTree = rootRoute.addChildren([
173
180
  indexRoute,
@@ -188,6 +195,7 @@ export const routeTree = rootRoute.addChildren([
188
195
  omnichatRoute,
189
196
  omnichatRuleRoute,
190
197
  gettingStartedRoute,
198
+ benchoRoute,
191
199
  ]);
192
200
 
193
201
  export const router = createRouter({ routeTree, defaultPreload: false, scrollRestoration: false });