diff --git a/README.md b/README.md index 7d78e3e..c56b892 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,17 @@ ![transcriptcut editor: video preview, transcript with word-level cuts, and a frame-thumbnail timeline](docs/public/screenshots/hero.png) -Edit video by editing its transcript, with a few explicit AI-assisted -actions (filler-word and long-pause removal) layered on top — **not** a -free-text "ask the AI to edit this" chat bar; an early version of that was -built and worked, then deliberately removed (see Status below and -`claude.md` section 3 for why). **Local-first and open source** — runs -entirely on your machine, no cloud account required. See the -[docs](https://amide-init.github.io/transcriptcut/) for more, and the +Edit video by editing its transcript, built for **video podcasts**: +long multi-speaker episodes in, podcast-ready audio, chapters, show notes, +an MP3 for your feed and vertical Shorts out. AI helps through explicit, +scoped buttons (filler words, speakers, chapters, show notes, highlights), +**not** a free-text "ask the AI to edit this" chat bar; an early version +of that was built and worked, then deliberately removed (see `claude.md` +section 3 for why). **Local-first and open source** — runs entirely on +your machine, no cloud account required. See the +[docs](https://amide-init.github.io/transcriptcut/), especially the +[Podcast Workflow](https://amide-init.github.io/transcriptcut/guide/podcasting) +guide, and the [issue tracker](https://github.com/amide-init/transcriptcut/issues) for current build status. @@ -37,8 +41,31 @@ Editing tools beyond manual transcript cuts: - **Captions** — font, size, color, position, and background-box styling, plus an optional word-by-word karaoke-style highlight. -Export renders the final MP4 with every preview effect above (filters, -properties, logo, captions) baked in — the CSS-preview ↔ ffmpeg-export +For podcasts: + +- **Long episodes** — uploads up to 10 GB; transcription runs in the + background in ~10-minute chunks split at pauses, with progress; a 720p + editing proxy keeps big 4K originals smooth in the browser. +- **Speakers** — "Detect speakers" labels who said what; rename a speaker + everywhere at once, or cut (and restore) everything one person said. +- **Audio** — a one-click podcast preset: -16 / -14 LUFS loudness, + noise reduction, speaker leveling and a rumble filter, with a + 15-second before/after preview. +- **Publishing** — AI chapters (built into MP4/MP3 exports and copyable as + YouTube timestamps), editable show notes and title ideas, TXT/Markdown + transcripts, and MP3/WAV export. +- **Clips / Shorts** — AI picks standalone 15-90s highlights, or clip a + transcript selection; render 9:16 / 1:1 / 16:9 with a positionable crop + and big word-by-word captions. + +AI models only ever return text and sentence picks, never timestamps or +edits: times always come from the transcript's own word timestamps, and +every response is validated before use. See the +[AI guide](https://amide-init.github.io/transcriptcut/guide/ai-editing) +for which model does what. + +Export renders the final MP4 (or MP3/WAV) with every preview effect above (filters, +properties, logo, captions, audio cleanup) baked in — the CSS-preview ↔ ffmpeg-export math for each is unit-tested (see Testing below) to catch the preview and the actual export drifting apart, which happened in practice for several of these (see the issue tracker's closed bugs). @@ -82,9 +109,10 @@ pnpm run dev ## Running the app -Requires [FFmpeg](https://ffmpeg.org/download.html) and +Requires [FFmpeg](https://ffmpeg.org/download.html) (with `ffprobe`) and [Bun](https://bun.sh) (the backend's runtime) on your machine, plus an -OpenAI API key (used server-side only, for transcription). +OpenAI API key (used server-side only, for transcription and the AI +buttons). ### macOS quick start @@ -117,6 +145,16 @@ Open [http://localhost:5173](http://localhost:5173). See [`server/.env.example`](./server/.env.example) for what each variable does, and [`CONTRIBUTING.md`](./CONTRIBUTING.md) for more. +Two things to know during development: + +- **The dev server and the Mac app both use port 3001**, so quit one + before starting the other. If the app shows "404 Not Found", a dev + server is still running. +- **`bun --watch` leaks a few file handles on every reload.** After a long + session with many server edits, FFmpeg can fail to start with + `EBADF: bad file descriptor, posix_spawn`. Restart `pnpm run dev` to + clear it. + ### Download A native macOS app is also available (Apple Silicon only) — no need to @@ -133,7 +171,9 @@ cd client pnpm exec tauri build ``` -Produces `client/src-tauri/target/release/bundle/{macos,dmg}/`. See +Produces `client/src-tauri/target/release/bundle/{macos,dmg}/`. Installing +a newer build over an older one keeps your projects: the server upgrades +the app's database on launch. See `claude.md` section 35 for how it's wired together, and [`.github/workflows/build-macos-app.yml`](./.github/workflows/build-macos-app.yml) for how CI builds and releases it on a version tag push. @@ -142,8 +182,11 @@ for how CI builds and releases it on a version tag push. Deterministic unit tests (Vitest) cover the core editing logic — timeline cut/trim math, transcript-to-caption mapping, the ffmpeg render-plan -builder, path-traversal safety, and the CSS-preview/ffmpeg-export formula -parity work described in the issue tracker: +builder (cuts, audio cleanup, reframing, chapter metadata), chunked +transcription and speaker alignment, chapter and highlight validation, +database migrations, path-traversal safety, and the +CSS-preview/ffmpeg-export formula parity work described in the issue +tracker: ```bash pnpm run test # runs both client and server test suites diff --git a/claude.md b/claude.md index 31c95eb..ad7e49d 100644 --- a/claude.md +++ b/claude.md @@ -217,7 +217,12 @@ Do not use GPT-5.6 Luna for every request. Transcription is a separate concern from editing and isn't routed through the GPT-4o-mini / GPT-5.6 Luna split above. Use OpenAI Whisper (`whisper-1`, `verbose_json`, word + segment timestamp granularity) — -already implemented in `lib/ai/transcribe.ts`. Keep it behind a clear +already implemented in `lib/ai/transcribe.ts`. Long episodes are handled +by `lib/ai/transcription-job.ts`: it extracts a mono 16kHz 32kbps speech +track, splits it into ~10-minute chunks at detected silences +(`lib/ai/chunking.ts`), transcribes the chunks in parallel, and merges them +back onto the source timeline, so source file size never hits Whisper's +25MB upload cap. Keep it behind a clear service interface (per section 29) so the provider can be swapped later. --- @@ -575,6 +580,62 @@ child process on the same machine as everything else (see section 18). --- +## Podcast audio cleanup + +Per-project `AudioSettings` (`server/src/types/audio-settings.ts`, stored as +`Project.audioSettingsJson`): loudness target (off / -16 / -14 LUFS), noise +reduction (afftdn), speaker leveling (dynaudnorm) and an 80Hz high-pass. +Enums/booleans only, mapped to fixed filter strings in +`lib/ffmpeg/audio-filters.ts`. Loudness is set by measured gain + a peak +limiter at -2 dBTP, not loudnorm's linear mode (which silently falls back +to dynamic mode and undershoots on peaky speech); `render-job.ts#resolveLoudnessGain` +measures and corrects with secant steps. Defaults are all off, so exports +are unchanged unless a user opts in. + +## Podcast publishing + +`PublishingMeta` (one per project) holds AI-generated, user-editable +chapters and show notes (`server/src/types/publishing.ts`). The model +(gpt-4o-mini) only returns text and *sentence indices* -- chapter starts +come from those sentences' own timestamps, never from a time the model +wrote. Chapters are stored in **source** time and mapped onto the edited +timeline when shown or exported (`lib/publishing/chapters.ts`), so later +cuts don't strand them. Every export (MP4, and MP3 at 44.1kHz/192k) embeds +the episode title and chapters via a generated FFMETADATA file; WAV can't +hold chapters. AI output never changes the edit. + +## Speaker detection + +An explicit "Detect speakers" action, not part of transcription: the +diarization model (`gpt-4o-transcribe-diarize`, behind `lib/ai/diarize.ts`) +runs at about half real time. It returns speaker-labeled spans but no word +timings, so Whisper stays the source of truth for words; diarization only +decides who said each word (`lib/ai/speakers.ts#assignSpeakers`), splitting +segments where the speaker changes. Labels are only consistent within one +API call, so `lib/ai/diarization-job.ts` keeps them consistent across +~10-minute chunks two ways: reference clips of the first chunk's voices +(`known_speaker_names`), and a 45s overlap between chunks used to match any +label the references missed. Results land in the existing `segment.speaker` +field ("Speaker 1", ...), so captions, exports and show notes use them with +no changes. Cutting a speaker is ordinary cut operations. Known limit: +similar-sounding voices can merge into one speaker. + +## Clips / Shorts + +`Clip` rows are source-time ranges with an aspect (9:16, 1:1, 16:9), a +horizontal crop position and a captions toggle. "Find highlights" is the +one feature routed to GPT-5.6 Luna (`gpt-5.6-luna`, section 4: selecting +important sections); like chapters, the model returns sentence indices, +never times, and `lib/clips/clips.ts#highlightsFromPicks` enforces length +(15-90s), whole sentences and no overlaps. A clip renders through the +normal export as the project **plus two extra cuts** (everything before +and after it), so the project's own edits and audio cleanup apply inside +it; `plan.ts` then crops/scales to the clip frame, and captions are +re-cut into 3-4 word Shorts cues laid out for that frame (the ASS script +resolution must match the output aspect, or libass stretches glyphs). + +--- + # 13. Architecture (local-first) No cloud account is required to run or develop this project. @@ -714,6 +775,8 @@ Transcript — belongs to Project EditOperation — belongs to Project RenderJob — belongs to Project + +TranscriptionJob — belongs to Project (background chunked transcription status/progress) ``` No `User` model, no ownership checks for v1 (see section 2) — every @@ -810,15 +873,34 @@ GET /api/projects GET /api/projects/:id POST /api/projects/:id/upload -POST /api/projects/:id/transcribe +POST /api/projects/:id/transcribe (202 + jobId; runs in the background) +GET /api/projects/:id/transcribe/:jobId (status + chunk progress) + +GET /api/projects/:id/video[?variant=proxy] +GET /api/projects/:id/audio (extracted speech track, for the waveform) GET /api/projects/:id/transcript POST /api/projects/:id/operations DELETE /api/projects/:id/operations/:operationId -POST /api/projects/:id/render +POST /api/projects/:id/render (body: format mp4|mp3|wav, captions) GET /api/projects/:id/render/:jobId +POST /api/projects/:id/render/audio-preview (15s before/after sample of the audio settings) + +GET /api/projects/:id/publishing (chapters on the edited timeline + show notes) +POST /api/projects/:id/publishing/chapters (AI-generate) PUT (save user edits) +POST /api/projects/:id/publishing/show-notes (AI-generate) PUT (save user edits) +GET /api/projects/:id/transcript/export?format=txt|md + +POST /api/projects/:id/speakers/detect (202 + jobId; background speaker detection) +GET /api/projects/:id/speakers/detect/:jobId +POST /api/projects/:id/speakers/rename ({from, to|null} on every segment) + +GET /api/projects/:id/clips +POST /api/projects/:id/clips/highlights (GPT-5.6 Luna picks; replaces earlier AI picks) +POST /api/projects/:id/clips PATCH/DELETE /api/projects/:id/clips/:clipId +(render a clip: POST /api/projects/:id/render with {clipId}) ``` (No `POST /api/projects/:id/ai/edit` — the free-text AI command bar this diff --git a/client/src-tauri/src/lib.rs b/client/src-tauri/src/lib.rs index 3836125..671a263 100644 --- a/client/src-tauri/src/lib.rs +++ b/client/src-tauri/src/lib.rs @@ -61,6 +61,13 @@ pub fn run() { "FFMPEG_PATH", "/opt/homebrew/opt/ffmpeg-full/bin/ffmpeg", ) + // Needed since transcription (audio duration), the editing + // proxy and the logo/captions sizing all probe media -- a + // bare "ffprobe" isn't on a GUI app's PATH, same as ffmpeg. + .env( + "FFPROBE_PATH", + "/opt/homebrew/opt/ffmpeg-full/bin/ffprobe", + ) .env( "CLIENT_DIST_DIR", server_dir.join("client-dist").display().to_string(), diff --git a/client/src/components/editor/AudioPanel.tsx b/client/src/components/editor/AudioPanel.tsx new file mode 100644 index 0000000..98fe08f --- /dev/null +++ b/client/src/components/editor/AudioPanel.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Sparkles } from "lucide-react"; +import { useProjectStore } from "@/stores/project-store"; +import { usePlayerStore } from "@/stores/player-store"; +import { Button } from "@/components/ui/button"; +import { + DEFAULT_AUDIO_SETTINGS, + PODCAST_AUDIO_PRESET, + type AudioSettings, + type DenoiseLevel, + type LoudnessTarget, +} from "@/types/audio-settings"; + +const LOUDNESS_OPTIONS: { value: LoudnessTarget; label: string; hint: string }[] = [ + { value: "off", label: "Off", hint: "Keep the original level" }, + { value: "-16", label: "-16 LUFS", hint: "Podcast apps (Apple, Spotify podcasts)" }, + { value: "-14", label: "-14 LUFS", hint: "YouTube and music streaming" }, +]; + +const DENOISE_OPTIONS: { value: DenoiseLevel; label: string }[] = [ + { value: "off", label: "Off" }, + { value: "light", label: "Light" }, + { value: "strong", label: "Strong" }, +]; + +type PreviewState = + | { status: "idle" } + | { status: "rendering" } + | { status: "ready"; before: string; after: string } + | { status: "failed"; message: string }; + +type PreviewJobResponse = + | { + success: true; + job: { + status: "queued" | "processing" | "completed" | "failed"; + error: string | null; + previewUrls: { before: string; after: string } | null; + }; + } + | { success: false; error: { message: string } }; + +const POLL_INTERVAL_MS = 1000; + +function sameSettings(a: AudioSettings, b: AudioSettings): boolean { + return ( + a.loudnessTarget === b.loudnessTarget && + a.denoise === b.denoise && + a.levelSpeakers === b.levelSpeakers && + a.highpass === b.highpass + ); +} + +function Segmented({ + options, + value, + onChange, +}: { + options: { value: T; label: string }[]; + value: T; + onChange: (value: T) => void; +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +/** + * Podcast audio cleanup: loudness target, noise reduction, speaker leveling + * and rumble removal, applied by ffmpeg at export. The browser can't run + * those filters live, so "Preview 15s" renders a short before/after sample + * from the playhead instead. + */ +export function AudioPanel() { + const projectId = useProjectStore((s) => s.id); + const settings = useProjectStore((s) => s.audioSettings); + const setSettings = useProjectStore((s) => s.setAudioSettings); + const currentTime = usePlayerStore((s) => s.currentTime); + const [preview, setPreview] = useState({ status: "idle" }); + const pollRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (pollRef.current) clearTimeout(pollRef.current); + }; + }, []); + + const update = (patch: Partial) => { + setSettings({ ...settings, ...patch }); + // A preview of the old settings would now be misleading. + setPreview({ status: "idle" }); + }; + + const pollPreview = (jobId: string) => { + pollRef.current = setTimeout(async () => { + try { + const res = await fetch(`/api/projects/${projectId}/render/${jobId}`); + const data: PreviewJobResponse = await res.json(); + if (!data.success) { + setPreview({ status: "failed", message: data.error.message }); + return; + } + if (data.job.status === "completed" && data.job.previewUrls) { + setPreview({ status: "ready", ...data.job.previewUrls }); + } else if (data.job.status === "failed") { + setPreview({ status: "failed", message: data.job.error ?? "Preview failed." }); + } else { + pollPreview(jobId); + } + } catch { + pollPreview(jobId); // transient network hiccup -- keep polling + } + }, POLL_INTERVAL_MS); + }; + + const startPreview = async () => { + if (!projectId) return; + if (pollRef.current) clearTimeout(pollRef.current); + setPreview({ status: "rendering" }); + try { + const res = await fetch(`/api/projects/${projectId}/render/audio-preview`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ start: currentTime, audioSettings: settings }), + }); + const data: { success: true; jobId: string } | { success: false; error: { message: string } } = + await res.json(); + if (!data.success) { + setPreview({ status: "failed", message: data.error.message }); + return; + } + pollPreview(data.jobId); + } catch { + setPreview({ status: "failed", message: "Could not start the preview. Try again." }); + } + }; + + const isPreset = sameSettings(settings, PODCAST_AUDIO_PRESET); + const isOff = sameSettings(settings, DEFAULT_AUDIO_SETTINGS); + const loudnessHint = LOUDNESS_OPTIONS.find((o) => o.value === settings.loudnessTarget)?.hint; + + return ( +
+ + +
+ Loudness + update({ loudnessTarget })} + /> + {loudnessHint} +
+ +
+ Noise reduction + update({ denoise })} /> +
+ + + + + +
+ Applied when you export. Hear it first: + + {isOff && Turn on a setting above to preview it.} + {preview.status === "failed" && {preview.message}} + {preview.status === "ready" && ( +
+
+ Before +
+
+ After +
+
+ )} +
+
+ ); +} diff --git a/client/src/components/editor/ClipsPanel.tsx b/client/src/components/editor/ClipsPanel.tsx new file mode 100644 index 0000000..f298f84 --- /dev/null +++ b/client/src/components/editor/ClipsPanel.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Download, Play, Sparkles, X } from "lucide-react"; +import { useProjectStore } from "@/stores/project-store"; +import { usePlayerStore } from "@/stores/player-store"; +import { useTranscriptStore } from "@/stores/transcript-store"; +import { useClipStore } from "@/stores/clip-store"; +import { Button } from "@/components/ui/button"; +import { CLIP_ASPECTS, CLIP_MAX_SECONDS, CLIP_MIN_SECONDS, type Clip } from "@/types/clips"; + +type ClipsResponse = { success: true; clips: Clip[] } | { success: false; error: { message: string } }; +type RenderState = + | { status: "rendering"; jobId: string } + | { status: "done"; downloadUrl: string } + | { status: "failed"; message: string }; + +const POLL_INTERVAL_MS = 2000; + +function formatTime(seconds: number): string { + const total = Math.max(0, Math.floor(seconds)); + const m = Math.floor(total / 60); + return `${m}:${String(total % 60).padStart(2, "0")}`; +} + +function ClipCard({ + clip, + focused, + render, + onFocus, + onChange, + onDelete, + onRender, +}: { + clip: Clip; + focused: boolean; + render: RenderState | undefined; + onFocus: () => void; + onChange: (patch: Partial>) => void; + onDelete: () => void; + onRender: () => void; +}) { + const [cropX, setCropX] = useState(clip.cropX); + const setFocused = useClipStore((s) => s.setFocused); + + return ( +
+
+ { + const name = e.target.value.trim(); + if (name && name !== clip.name) onChange({ name }); + }} + className="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 font-medium hover:border-border focus:border-primary focus:outline-none" + /> + +
+
+ + {formatTime(clip.sourceStart)}–{formatTime(clip.sourceEnd)} + + {Math.round(clip.sourceEnd - clip.sourceStart)}s + {clip.source === "ai" && AI pick} +
+ {clip.reason &&

{clip.reason}

} + + {focused && ( +
e.stopPropagation()}> +
+ {CLIP_ASPECTS.map((aspect) => ( + + ))} +
+ {clip.aspect !== "16:9" && ( + + )} + +
+ + {render?.status === "done" && ( + + )} +
+ {render?.status === "failed" && {render.message}} +
+ )} +
+ ); +} + +/** + * Clips / Shorts: AI-picked highlights (GPT-5.6 Luna, server side) or clips + * made from a transcript selection, each rendered reframed with Shorts-style + * captions. The project's cuts still apply inside a clip. + */ +export function ClipsPanel() { + const projectId = useProjectStore((s) => s.id); + const seek = usePlayerStore((s) => s.seek); + const videoElement = usePlayerStore((s) => s.videoElement); + const transcript = useTranscriptStore((s) => s.transcript); + const selectedWordIds = useTranscriptStore((s) => s.selectedWordIds); + const clearSelection = useTranscriptStore((s) => s.clearSelection); + const focused = useClipStore((s) => s.focused); + const setFocused = useClipStore((s) => s.setFocused); + + const [clips, setClips] = useState([]); + const [finding, setFinding] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [renders, setRenders] = useState>({}); + const timers = useRef[]>([]); + + useEffect(() => { + const pending = timers.current; + return () => { + pending.forEach(clearTimeout); + setFocused(null); + }; + }, [setFocused]); + + const call = useCallback( + async (path: string, method: string, body?: unknown) => { + if (!projectId) return null; + try { + const res = await fetch(`/api/projects/${projectId}/clips${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const data: ClipsResponse & { found?: number } = await res.json(); + if (data.success) { + setClips(data.clips); + setError(null); + } else { + setError(data.error.message); + } + return data; + } catch { + setError("Could not reach the server. Try again."); + return null; + } + }, + [projectId] + ); + + useEffect(() => { + void call("", "GET"); + }, [call]); + + const findHighlights = async () => { + setFinding(true); + setMessage(null); + const data = await call("/highlights", "POST"); + if (data?.success) { + setMessage(data.found ? `Found ${data.found} clip${data.found === 1 ? "" : "s"}.` : "No strong standalone moments found."); + } + setFinding(false); + }; + + const selectedWords = (transcript?.segments.flatMap((s) => s.words) ?? []).filter((w) => + selectedWordIds.includes(w.id) + ); + const selectionStart = selectedWords.length ? Math.min(...selectedWords.map((w) => w.start)) : 0; + const selectionEnd = selectedWords.length ? Math.max(...selectedWords.map((w) => w.end)) : 0; + const selectionLength = selectionEnd - selectionStart; + const selectionValid = selectedWords.length > 0 && selectionLength >= CLIP_MIN_SECONDS && selectionLength <= CLIP_MAX_SECONDS; + + const clipFromSelection = async () => { + const data = await call("", "POST", { sourceStart: Math.max(0, selectionStart - 0.1), sourceEnd: selectionEnd + 0.3 }); + if (data?.success) clearSelection(); + }; + + const focus = (clip: Clip) => { + setFocused({ id: clip.id, aspect: clip.aspect, cropX: clip.cropX }); + seek(clip.sourceStart); + }; + + const pollRender = (clipId: string, jobId: string) => { + const timer = setTimeout(async () => { + try { + const res = await fetch(`/api/projects/${projectId}/render/${jobId}`); + const data: + | { success: true; job: { status: string; error: string | null; downloadUrl: string | null } } + | { success: false; error: { message: string } } = await res.json(); + if (!data.success) { + setRenders((r) => ({ ...r, [clipId]: { status: "failed", message: data.error.message } })); + } else if (data.job.status === "completed" && data.job.downloadUrl) { + setRenders((r) => ({ ...r, [clipId]: { status: "done", downloadUrl: data.job.downloadUrl! } })); + } else if (data.job.status === "failed") { + setRenders((r) => ({ ...r, [clipId]: { status: "failed", message: data.job.error ?? "Render failed." } })); + } else { + pollRender(clipId, jobId); + } + } catch { + pollRender(clipId, jobId); + } + }, POLL_INTERVAL_MS); + timers.current.push(timer); + }; + + const renderClip = async (clip: Clip) => { + try { + const res = await fetch(`/api/projects/${projectId}/render`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ clipId: clip.id }), + }); + const data: { success: true; jobId: string } | { success: false; error: { message: string } } = + await res.json(); + if (!data.success) { + setRenders((r) => ({ ...r, [clip.id]: { status: "failed", message: data.error.message } })); + return; + } + setRenders((r) => ({ ...r, [clip.id]: { status: "rendering", jobId: data.jobId } })); + pollRender(clip.id, data.jobId); + } catch { + setRenders((r) => ({ ...r, [clip.id]: { status: "failed", message: "Could not start the render." } })); + } + }; + + return ( +
+ + + + {selectedWords.length === 0 + ? "Or select words in the transcript (shift-click for a range) to make your own clip." + : selectionValid + ? `Selection: ${Math.round(selectionLength)}s` + : `Selection is ${Math.round(selectionLength)}s -- clips need ${CLIP_MIN_SECONDS}s to ${CLIP_MAX_SECONDS / 60} min.`} + + {message && {message}} + {error && {error}} + + {clips.length > 0 && ( +
+ {clips.map((clip) => ( + { + if (focused?.id !== clip.id) focus(clip); + }} + onChange={(patch) => void call(`/${clip.id}`, "PATCH", patch)} + onDelete={() => { + if (focused?.id === clip.id) setFocused(null); + void call(`/${clip.id}`, "DELETE"); + }} + onRender={() => void renderClip(clip)} + /> + ))} +
+ )} + + {focused && ( + + )} +
+ ); +} diff --git a/client/src/components/editor/EditorHydrator.tsx b/client/src/components/editor/EditorHydrator.tsx index 51676a9..1c90dca 100644 --- a/client/src/components/editor/EditorHydrator.tsx +++ b/client/src/components/editor/EditorHydrator.tsx @@ -10,6 +10,7 @@ import type { Transcript } from "@/types/transcript"; import type { EditOperation } from "@/types/edit-operation"; import type { CaptionStyle } from "@/lib/captions/style"; import type { VideoProperties } from "@/types/video-properties"; +import type { AudioSettings } from "@/types/audio-settings"; import type { LogoPosition } from "@/lib/video/logo"; type HydrateProject = { @@ -19,11 +20,13 @@ type HydrateProject = { burnInCaptions: boolean; captionStyle: CaptionStyle | null; properties: VideoProperties | null; + audioSettings: AudioSettings | null; logoPosition: LogoPosition; logoPaddingX: number; logoPaddingY: number; logoOpacity: number; videoUrl: string | null; + audioUrl: string | null; logoUrl: string | null; }; diff --git a/client/src/components/editor/EditorSidebar.tsx b/client/src/components/editor/EditorSidebar.tsx index dcaf720..670fcbb 100644 --- a/client/src/components/editor/EditorSidebar.tsx +++ b/client/src/components/editor/EditorSidebar.tsx @@ -1,17 +1,23 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Captions, ChevronLeft, ChevronRight, ImagePlus, SlidersHorizontal, Sliders, Wand2 } from "lucide-react"; +import { AudioLines, BookOpenText, Captions, ChevronLeft, Clapperboard, ChevronRight, ImagePlus, SlidersHorizontal, Sliders, Wand2 } from "lucide-react"; import { AiToolsPanel } from "@/components/editor/AiToolsPanel"; import { FiltersPanel } from "@/components/editor/FiltersPanel"; import { PropertiesPanel } from "@/components/editor/PropertiesPanel"; import { ElementsPanel } from "@/components/editor/ElementsPanel"; import { CaptionsPanel } from "@/components/editor/CaptionsPanel"; +import { AudioPanel } from "@/components/editor/AudioPanel"; +import { PublishPanel } from "@/components/editor/PublishPanel"; +import { ClipsPanel } from "@/components/editor/ClipsPanel"; -type PanelId = "captions" | "ai" | "filters" | "properties" | "elements"; +type PanelId = "captions" | "audio" | "publish" | "clips" | "ai" | "filters" | "properties" | "elements"; const TABS: { id: PanelId; label: string; icon: typeof Wand2 }[] = [ { id: "captions", label: "Captions", icon: Captions }, + { id: "audio", label: "Audio", icon: AudioLines }, + { id: "publish", label: "Publish", icon: BookOpenText }, + { id: "clips", label: "Clips", icon: Clapperboard }, { id: "ai", label: "AI tools", icon: Wand2 }, { id: "filters", label: "Filters", icon: SlidersHorizontal }, { id: "elements", label: "Elements", icon: ImagePlus }, @@ -103,6 +109,9 @@ export function EditorSidebar() {
{activePanel === "captions" && } + {activePanel === "audio" && } + {activePanel === "publish" && } + {activePanel === "clips" && } {activePanel === "ai" && } {activePanel === "filters" && } {activePanel === "elements" && } diff --git a/client/src/components/editor/ExportButton.tsx b/client/src/components/editor/ExportButton.tsx index 4af0fa8..07e9ec8 100644 --- a/client/src/components/editor/ExportButton.tsx +++ b/client/src/components/editor/ExportButton.tsx @@ -5,6 +5,13 @@ import { Download, Upload } from "lucide-react"; import { useProjectStore } from "@/stores/project-store"; import { useCaptionStyleStore } from "@/stores/caption-style-store"; import { Button } from "@/components/ui/button"; +import type { ExportFormat } from "@/types/publishing"; + +const FORMAT_OPTIONS: { value: ExportFormat; label: string }[] = [ + { value: "mp4", label: "MP4 video" }, + { value: "mp3", label: "MP3 audio" }, + { value: "wav", label: "WAV audio" }, +]; type JobStatus = "queued" | "processing" | "completed" | "failed"; type JobResponse = @@ -18,6 +25,7 @@ export function ExportButton() { const [status, setStatus] = useState(null); const [downloadUrl, setDownloadUrl] = useState(null); const [error, setError] = useState(null); + const [format, setFormat] = useState("mp4"); const burnInCaptions = useCaptionStyleStore((s) => s.burnInCaptions); const captionStyle = useCaptionStyleStore((s) => s.style); const pollRef = useRef | null>(null); @@ -65,6 +73,7 @@ export function ExportButton() { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ + format, burnInCaptions, captionStyle: burnInCaptions ? captionStyle : undefined, }), @@ -101,10 +110,25 @@ export function ExportButton() { ) : ( - + <> + + + )}
); diff --git a/client/src/components/editor/PublishPanel.tsx b/client/src/components/editor/PublishPanel.tsx new file mode 100644 index 0000000..3be18f1 --- /dev/null +++ b/client/src/components/editor/PublishPanel.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Check, Copy, Plus, X } from "lucide-react"; +import { useProjectStore } from "@/stores/project-store"; +import { usePlayerStore } from "@/stores/player-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { Button } from "@/components/ui/button"; +import type { ResolvedChapter, ShowNotes } from "@/types/publishing"; + +type PublishingResponse = + | { success: true; chapters: ResolvedChapter[]; youtubeChapters: string; showNotes: ShowNotes | null } + | { success: false; error: { message: string } }; + +type Publishing = Omit, "success">; + +function formatTime(seconds: number): string { + const total = Math.max(0, Math.floor(seconds)); + const h = Math.floor(total / 3600); + const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0"); + const s = String(total % 60).padStart(2, "0"); + return h > 0 ? `${h}:${m}:${s}` : `${m}:${s}`; +} + +/** Copy-to-clipboard button that briefly shows a check mark. */ +function CopyButton({ text, label }: { text: string; label: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function SectionHeading({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +/** Show notes as Markdown: summary, key points, and chapters -- ready to paste into a podcast host or YouTube. */ +function showNotesMarkdown(notes: ShowNotes, youtubeChapters: string): string { + const parts = [notes.summary]; + if (notes.keyPoints.length > 0) parts.push(notes.keyPoints.map((p) => `- ${p}`).join("\n")); + if (youtubeChapters) parts.push(`Chapters:\n${youtubeChapters}`); + return parts.join("\n\n"); +} + +/** + * Podcast publishing helpers: AI chapters (editable, embedded into exported + * MP4/MP3 and copyable as YouTube timestamps), AI show notes and title + * ideas, and transcript downloads. AI output is text only -- it never + * changes the edit. + */ +export function PublishPanel() { + const projectId = useProjectStore((s) => s.id); + const currentTime = usePlayerStore((s) => s.currentTime); + const seek = usePlayerStore((s) => s.seek); + const operations = useTimelineStore((s) => s.operations); + + const [data, setData] = useState(null); + const [busy, setBusy] = useState<"chapters" | "notes" | null>(null); + const [error, setError] = useState(null); + + const apply = useCallback((res: PublishingResponse) => { + if (res.success) { + setData({ chapters: res.chapters, youtubeChapters: res.youtubeChapters, showNotes: res.showNotes }); + setError(null); + } else { + setError(res.error.message); + } + }, []); + + const request = useCallback( + async (path: string, method: "GET" | "POST" | "PUT", body?: unknown) => { + if (!projectId) return; + try { + const res = await fetch(`/api/projects/${projectId}/publishing${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + apply(await res.json()); + } catch { + setError("Could not reach the server. Try again."); + } + }, + [projectId, apply] + ); + + // Chapter times are placed on the edited timeline server-side, so refresh + // whenever the edit changes. + useEffect(() => { + void request("", "GET"); + }, [request, operations]); + + const generate = async (kind: "chapters" | "notes") => { + setBusy(kind); + await request(kind === "chapters" ? "/chapters" : "/show-notes", "POST"); + setBusy(null); + }; + + const saveChapters = (chapters: { title: string; sourceStart: number }[]) => + request("/chapters", "PUT", { chapters: chapters.map(({ title, sourceStart }) => ({ title, sourceStart })) }); + + const saveNotes = (notes: ShowNotes) => request("/show-notes", "PUT", notes); + + const chapters = data?.chapters ?? []; + const notes = data?.showNotes ?? null; + + return ( +
+ {error &&

{error}

} + +
+
+ Chapters + {chapters.length > 0 && } +
+ + {chapters.length > 0 && ( +
    + {chapters.map((chapter, i) => ( +
  • + + { + const title = e.target.value.trim(); + if (title && title !== chapter.title) { + void saveChapters(chapters.map((c, j) => (j === i ? { ...c, title } : c))); + } + }} + className="min-w-0 flex-1 rounded border border-transparent bg-transparent px-1 py-0.5 hover:border-border focus:border-primary focus:outline-none" + /> + +
  • + ))} +
+ )} + + Embedded in MP4 and MP3 exports. +
+ +
+
+ Show notes + {notes && } +
+ + {notes && ( +
+