From 1183d698494ddb97766ea4a8ae6aa3985471157e Mon Sep 17 00:00:00 2001 From: glass beadian Date: Wed, 14 Oct 2026 20:14:20 +0000 Subject: [PATCH] feat: compose media attachments UI + video producer panel (t2+t5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose: upload picked files to /media with progress, thumbnails via the auth-scoped serve route, per-attachment alt text, remove, ≤4 cap with the Mastodon norm hint, in-flight blocking. Video drawer: Producer section — lower-third editor, logo key field, MediaRecorder voiceover (record/ stop/review/re-take/upload), produce submission with lane + label, job progress, derived-asset link. Local types for both contracts. --- apps/web/src/app/(main)/compose/page.tsx | 57 +- apps/web/src/app/(main)/video/page.tsx | 10 +- .../src/components/compose/attachments.tsx | 235 ++++++++ .../src/components/video/producer-panel.tsx | 510 ++++++++++++++++++ .../components/video/video-detail-drawer.tsx | 12 +- apps/web/src/lib/media-types.ts | 55 ++ apps/web/src/lib/video-types.ts | 41 ++ 7 files changed, 903 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/compose/attachments.tsx create mode 100644 apps/web/src/components/video/producer-panel.tsx create mode 100644 apps/web/src/lib/media-types.ts diff --git a/apps/web/src/app/(main)/compose/page.tsx b/apps/web/src/app/(main)/compose/page.tsx index 6904ebd..464584c 100644 --- a/apps/web/src/app/(main)/compose/page.tsx +++ b/apps/web/src/app/(main)/compose/page.tsx @@ -10,10 +10,12 @@ import { Badge, Button, Input } from "../../../components/ui/primitives"; import { OfflineBadge } from "../../../components/ui/offline-badge"; import { MOCK_TARGETS, PLATFORMS } from "../../../lib/platforms"; import { api, errorMessage } from "../../../lib/api-client"; -import type { ConnectionSummary, DraftPost, DraftRecord, DraftState, ScheduledPublicationRecord, Visibility } from "../../../lib/api-types"; +import type { ConnectionSummary, DraftRecord, DraftState, ScheduledPublicationRecord, Visibility } from "../../../lib/api-types"; import { useAuth } from "../../../lib/auth-store"; import { useApiResource } from "../../../lib/use-api"; import { Sheet } from "../../../components/mobile/sheet"; +import { AttachmentsRow, useComposeAttachments } from "../../../components/compose/attachments"; +import { MEDIA_ATTACH_HINT, MEDIA_MAX_ATTACHMENTS, type MediaDraftPost } from "../../../lib/media-types"; /** A publish target: a real connection when live, a fixture platform when offline. */ interface Target { @@ -62,9 +64,7 @@ export default function ComposePage(): React.ReactElement { const [scheduleOpen, setScheduleOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false); const [keyboardOffset, setKeyboardOffset] = useState(0); - const [media, setMedia] = useState>([]); - const mediaRef = useRef(media); - mediaRef.current = media; + const media = useComposeAttachments(); // Default selection: the first available target, until the user picks. const targets = (picked ?? available.slice(0, 1).map((t) => t.key)).filter((k) => available.some((t) => t.key === k)); @@ -86,7 +86,11 @@ export default function ComposePage(): React.ReactElement { const chars = editor?.storage.characterCount.characters() ?? 0; const over = chars > limit; - const mediaBlocked = media.length > 0; + /** Never post a half-uploaded attachment set: the draft would silently drop bytes. */ + const mediaBusy = media.uploading; + const mediaBroken = media.attachments.some((item) => item.error !== null); + const dragDepth = useRef(0); + const [dragging, setDragging] = useState(false); useEffect(() => { const viewport = window.visualViewport; if (!viewport) return; @@ -104,12 +108,12 @@ export default function ComposePage(): React.ReactElement { const requested = params.get("targets")?.split(",").filter(Boolean); if (requested?.length) setPicked(requested); }, [editor]); - useEffect(() => () => { mediaRef.current.forEach((item) => URL.revokeObjectURL(item.url)); }, []); - function postFor(providerMetadata: Record): DraftPost { + function postFor(providerMetadata: Record): MediaDraftPost { return { text: editor?.getText() ?? "", - mediaAssetIds: [], + mediaAssetIds: media.mediaAssetIds, + ...(media.altTexts.length > 0 ? { altTexts: media.altTexts } : {}), visibility, providerMetadata, ...(scheduledAt !== "" ? { scheduledAt: new Date(scheduledAt).toISOString() } : {}), @@ -196,20 +200,39 @@ export default function ComposePage(): React.ReactElement { })} -
+
{ event.preventDefault(); dragDepth.current += 1; setDragging(true); }} + onDragOver={(event) => { event.preventDefault(); if (event.dataTransfer !== null) event.dataTransfer.dropEffect = "copy"; }} + onDragLeave={(event) => { event.preventDefault(); dragDepth.current = Math.max(0, dragDepth.current - 1); if (dragDepth.current === 0) setDragging(false); }} + onDrop={(event) => { event.preventDefault(); dragDepth.current = 0; setDragging(false); media.addFiles(event.dataTransfer.files); }} + className={`relative min-h-[220px] flex-1 overflow-y-auto rounded-lg border border-border-subtle bg-surface p-4 scrollbar-thin transition-colors duration-150 ${dragging ? "border-accent ring-1 ring-accent/40" : ""}`} + > + {dragging &&
Drop to attach
}
= limit * 0.9 ? "border-warn text-warn" : "border-accent text-accent"}`}>{Math.max(0, limit - chars)}
{selected.map((target) => { const max = PLATFORMS[target.provider]?.charLimit ?? 500; return ; })}
- {media.length > 0 &&
{media.map((item) =>
{item.alt
setMedia((current) => current.map((value) => value.url === item.url ? { ...value, alt: event.target.value } : value))} placeholder="ALT text" className="mt-1 min-h-11 w-full bg-transparent text-2xs outline-none" />{item.alt ? "ALT ready" : "Add ALT"}
)}
} - {mediaBlocked &&

Media upload is not available in the publishing API yet. Remove previews to save or post text.

} +
+ + {media.attachments.length}/{MEDIA_MAX_ATTACHMENTS} · {MEDIA_ATTACH_HINT} +
+ + {media.warning !== null &&

{media.warning}

} {!can("approval.grant") &&

Your role submits posts for editor approval.

}
+ + {media.attachments.length}/{MEDIA_MAX_ATTACHMENTS} · {MEDIA_ATTACH_HINT} {chars} / {limit} @@ -252,18 +275,24 @@ export default function ComposePage(): React.ReactElement { />
- - +
+ {mediaBusy && ( +

Uploading media… {media.attachments.filter((item) => item.assetId !== null && item.error === null).length}/{media.attachments.filter((item) => item.error === null).length} ready

+ )} + {mediaBroken && !mediaBusy && ( +

An attachment failed to upload — retry or remove it before posting.

+ )} {notice !== null && (

{notice.text}

)}

{approval === "review" || !can("approval.grant") ? "→ Editors approve" : "→ Ready to publish"} · {scheduledAt ? `publishes ${new Date(scheduledAt).toLocaleString()}` : "post now"} · {selected.length} accounts

-
+
setScheduleOpen(false)} title="Schedule post" footer={}> setScheduledAt(event.target.value)} className="mt-2 min-h-12" />
{[1, 24, 48].map((hours) => )}
setPreviewOpen(false)} title="Target previews">{selected.map((target) =>

{target.label}

{editor?.getText() || "Your post preview appears here…"}

)}
diff --git a/apps/web/src/app/(main)/video/page.tsx b/apps/web/src/app/(main)/video/page.tsx index 7da4dfc..84edc8f 100644 --- a/apps/web/src/app/(main)/video/page.tsx +++ b/apps/web/src/app/(main)/video/page.tsx @@ -229,7 +229,15 @@ export default function VideoPage(): React.ReactElement { rendering and transcoding run separately in the media worker.

- {selected !== null && setSelectedId(null)} />} + {selected !== null && ( + setSelectedId(null)} + /> + )} {confirmDelete !== null && ( ; + addFiles: (files: FileList | File[]) => void; + setAlt: (key: string, alt: string) => void; + remove: (key: string) => void; + retry: (key: string) => void; +} { + const [attachments, setAttachments] = useState([]); + const [warning, setWarning] = useState(null); + const attachmentsRef = useRef(attachments); + attachmentsRef.current = attachments; + + // Every object URL minted here must die with the page, not only on a remove click. + useEffect( + () => () => { + attachmentsRef.current.forEach((item) => { + if (item.previewUrl !== null) URL.revokeObjectURL(item.previewUrl); + }); + }, + [], + ); + + const patch = useCallback((key: string, changes: Partial): void => { + setAttachments((current) => current.map((item) => (item.key === key ? { ...item, ...changes } : item))); + }, []); + + const upload = useCallback( + async (key: string, file: File): Promise => { + try { + const asset = await uploadMediaAsset(file, (percent) => patch(key, { progress: percent })); + let previewUrl: string | null = null; + try { + previewUrl = await fetchMediaObjectUrl(asset.id); + } catch { + // The asset is attached and postable even if its thumbnail cannot load. + } + patch(key, { assetId: asset.id, previewUrl, progress: 100, error: null }); + } catch (err) { + patch(key, { error: errorMessage(err) }); + } + }, + [patch], + ); + + /** Picks files, enforcing the ≤4 / 1-video cap before anything is uploaded. */ + const addFiles = useCallback( + (files: FileList | File[]): void => { + const picked = Array.from(files); + if (picked.length === 0) return; + // Failed picks do not hold a slot: they are removed or retried, not counted. + const held = attachmentsRef.current.filter((item) => item.error === null); + const accepted: Array<{ file: File; kind: "image" | "video" }> = []; + const rejected: string[] = []; + for (const file of picked) { + const kind = kindOf(file.type); + if (kind === null) { + rejected.push(`${file.name}: only images and video can be attached`); + continue; + } + const next = [...held.map((item) => item.kind), ...accepted.map((entry) => entry.kind)]; + if (next.length + 1 > MEDIA_MAX_ATTACHMENTS) { + rejected.push(`${file.name}: at most ${MEDIA_MAX_ATTACHMENTS} attachments per post`); + continue; + } + if (next.includes("video") || (kind === "video" && next.length > 0)) { + rejected.push(`${file.name}: a video must be the only attachment`); + continue; + } + accepted.push({ file, kind }); + } + setWarning(rejected.length > 0 ? rejected.join(" · ") : null); + if (accepted.length === 0) return; + const entries: ComposeAttachment[] = accepted.map(({ file, kind }) => ({ + key: `${file.name}:${file.size}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`, + file, + name: file.name, + kind, + progress: 0, + assetId: null, + previewUrl: null, + alt: "", + error: null, + })); + setAttachments((current) => [...current, ...entries]); + entries.forEach((entry) => { + void upload(entry.key, entry.file); + }); + }, + [upload], + ); + + const setAlt = useCallback( + (key: string, alt: string): void => { + patch(key, { alt }); + }, + [patch], + ); + + const remove = useCallback((key: string): void => { + setAttachments((current) => { + const victim = current.find((item) => item.key === key); + if (victim !== undefined && victim.previewUrl !== null) URL.revokeObjectURL(victim.previewUrl); + const rest = current.filter((item) => item.key !== key); + if (rest.length === 0) setWarning(null); + return rest; + }); + }, []); + + const retry = useCallback( + (key: string): void => { + const victim = attachmentsRef.current.find((item) => item.key === key); + if (victim === undefined || victim.error === null) return; + patch(key, { error: null, progress: 0 }); + void upload(key, victim.file); + }, + [patch, upload], + ); + + const settled = attachments.filter((item) => item.error === null && item.assetId !== null); + const held = attachments.filter((item) => item.error === null); + return { + attachments, + warning, + /** True while any pick is still in flight — posting must wait for these. */ + uploading: attachments.some((item) => item.error === null && item.assetId === null), + full: held.length >= MEDIA_MAX_ATTACHMENTS || held.some((item) => item.kind === "video"), + mediaAssetIds: settled.map((item) => item.assetId as string), + altTexts: settled.map((item) => (item.alt.trim() === "" ? null : item.alt.trim())), + addFiles, + setAlt, + remove, + retry, + }; +} + +/** Thumbnail strip shared by the mobile composer and the desktop compose column. */ +export function AttachmentsRow({ + attachments, + onAltChange, + onRemove, + onRetry, +}: { + attachments: ComposeAttachment[]; + onAltChange: (key: string, alt: string) => void; + onRemove: (key: string) => void; + onRetry: (key: string) => void; +}): React.ReactElement | null { + if (attachments.length === 0) return null; + return ( +
+ {attachments.map((item) => { + const pending = item.assetId === null && item.error === null; + return ( +
+
+ {item.previewUrl !== null && item.kind === "image" && ( + {item.alt + )} + {item.previewUrl !== null && item.kind === "video" && ( +
+ onAltChange(item.key, event.target.value)} + placeholder="ALT text" + className="mt-1 min-h-11 w-full bg-transparent text-2xs outline-none disabled:opacity-50" + /> + {item.alt ? "ALT ready" : "Add ALT"} +
+ {item.error !== null ? ( + + ) : ( + {item.kind} + )} + +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/video/producer-panel.tsx b/apps/web/src/components/video/producer-panel.tsx new file mode 100644 index 0000000..d53b8f4 --- /dev/null +++ b/apps/web/src/components/video/producer-panel.tsx @@ -0,0 +1,510 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Badge, Button, Input } from "../ui/primitives"; +import { Loading } from "../brand/loading"; +import { api, apiUploadFile, errorMessage } from "../../lib/api-client"; +import type { + VideoLibraryEntryDto, + VideoProcessingJobDto, + VideoProcessingState, + VideoOverlayPosition, + VideoProduceLane, + VideoProduceRequest, + VideoProduceResultDto, + VideoStatusDto, +} from "../../lib/video-types"; +import { formatBytes, formatDuration } from "./format"; + +/** + * Producer panel in the detail drawer: lower-third overlay, a voiceover + * recorded in the browser (MediaRecorder, audio/webm;opus) that is uploaded + * through the existing POST /video/upload multipart endpoint, gain/duck mix + * controls, and the POST /video/:id/produce submit. The produced asset is a + * derived asset, so it lands in the library once the job completes. + */ + +const LOWER_THIRD_MAX = 200; +const GAIN_MIN_DB = -20; +const GAIN_MAX_DB = 10; +const POLL_MS = 4_000; +const IN_FLIGHT: VideoProcessingState[] = ["queued", "probing", "transcoding"]; + +const POSITION_CHOICES: ReadonlyArray<{ value: string; label: string }> = [ + { value: "bottom-left", label: "Bottom left" }, + { value: "bottom-center", label: "Bottom center" }, + { value: "bottom-right", label: "Bottom right" }, +]; + +const LANE_CHOICES: ReadonlyArray<{ value: VideoProduceLane; label: string }> = [ + { value: "local", label: "Local" }, + { value: "remote", label: "Remote" }, +]; + +type RecorderPhase = "idle" | "recording" | "recorded"; + +interface VoiceoverDraft { + blob: Blob; + durationMs: number; + sizeBytes: number; +} + +interface ProduceOutcome { + job: VideoProcessingJobDto; + producedAssetId?: string; + outputKey?: string; +} + +interface Props { + entry: VideoLibraryEntryDto; + live: boolean; + canManage: boolean; +} + +export function ProducerPanel({ entry, live, canManage }: Props): React.ReactElement { + const [lowerThird, setLowerThird] = useState(""); + const [position, setPosition] = useState("bottom-left"); + const [logoKey, setLogoKey] = useState(""); + const [outputLabel, setOutputLabel] = useState(""); + const [lane, setLane] = useState("local"); + const [gainDb, setGainDb] = useState(0); + const [duckOriginal, setDuckOriginal] = useState(true); + + const [recorderSupported] = useState( + () => typeof window !== "undefined" && "MediaRecorder" in window && "MediaRecorder" in navigator, + ); + const [micError, setMicError] = useState(null); + const [phase, setPhase] = useState("idle"); + const [recorded, setRecorded] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadPercent, setUploadPercent] = useState(0); + const [voiceoverAssetId, setVoiceoverAssetId] = useState(null); + + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [outcome, setOutcome] = useState(null); + const [producedStatus, setProducedStatus] = useState(null); + + const recorderRef = useRef(null); + const streamRef = useRef(null); + const chunksRef = useRef([]); + const recordedAtRef = useRef(0); + const previewTimerRef = useRef | null>(null); + const pollRef = useRef | null>(null); + const entryRef = useRef(entry); + entryRef.current = entry; + + // Revoke preview blobs / stop timers on unmount (the drawer remounts per asset). + useEffect( + () => () => { + if (previewUrl !== null) URL.revokeObjectURL(previewUrl); + if (previewTimerRef.current !== null) clearInterval(previewTimerRef.current); + if (pollRef.current !== null) clearInterval(pollRef.current); + }, + [previewUrl], + ); + + const stopPolling = (): void => { + if (pollRef.current !== null) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + + const stopRecording = (): void => { + const recorder = recorderRef.current; + if (recorder !== null && recorder.state !== "inactive") recorder.stop(); + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + recorderRef.current = null; + }; + + const startRecording = (): void => { + if (!recorderSupported) return; + setMicError(null); + navigator.mediaDevices + .getUserMedia({ audio: true, video: false }) + .then((captured) => { + streamRef.current = captured; + chunksRef.current = []; + const recorder = new MediaRecorder(captured, { + mimeType: MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm", + }); + recorder.ondataavailable = (e: BlobEvent) => { + if (e.data.size > 0) chunksRef.current.push(e.data); + }; + recorder.onstop = () => { + const blob = new Blob(chunksRef.current, { type: "audio/webm" }); + if (blob.size === 0) { + setMicError("The recording came back empty — try again."); + setPhase("idle"); + return; + } + const durationMs = Math.max(1, Math.round(performance.now() - recordedAtRef.current)); + setRecorded({ blob, durationMs, sizeBytes: blob.size }); + setPhase("recorded"); + }; + recorder.start(); + recorderRef.current = recorder; + recordedAtRef.current = performance.now(); + setPhase("recording"); + }) + .catch((err: unknown) => { + setMicError( + err instanceof DOMException && (err.name === "NotAllowedError" || err.name === "SecurityError") + ? "Microphone access was denied — allow it in the browser and record again." + : `Could not start the microphone: ${errorMessage(err)}`, + ); + }); + }; + + // While the level meter is live, keep the preview element playing back the + // just-recorded take so the producer can audition it before uploading. + useEffect(() => { + if (phase !== "recorded" || recorded === null) return; + const url = URL.createObjectURL(recorded.blob); + setPreviewUrl(url); + previewTimerRef.current = setInterval(() => undefined, 60_000); + return () => { + if (previewTimerRef.current !== null) clearInterval(previewTimerRef.current); + URL.revokeObjectURL(url); + }; + }, [phase, recorded]); + + const uploadVoiceover = async (): Promise => { + if (recorded === null) return; + setUploading(true); + setUploadPercent(0); + setSubmitError(null); + try { + const file = new File([recorded.blob], "voiceover.webm", { type: "audio/webm" }); + const result = await apiUploadFile("/video/upload", file, setUploadPercent); + setVoiceoverAssetId(result.videoAsset.id); + setRecorded(null); + setPhase("idle"); + } catch (err) { + setSubmitError(`Voiceover upload failed: ${errorMessage(err)}`); + } finally { + setUploading(false); + } + }; + + const canSubmit = + live && + canManage && + !submitting && + (lowerThird.trim().length > 0 || + logoKey.trim().length > 0 || + outputLabel.trim().length > 0 || + voiceoverAssetId !== null); + + const submit = async (): Promise => { + if (!canSubmit) return; + setSubmitting(true); + setSubmitError(null); + setOutcome(null); + setProducedStatus(null); + stopPolling(); + const body: VideoProduceRequest = { + ...(lowerThird.trim().length > 0 + ? { + overlays: [ + { + kind: "lower-third" as const, + text: lowerThird.trim(), + position: position as VideoOverlayPosition, + }, + ], + } + : {}), + ...(voiceoverAssetId !== null + ? { voiceover: { assetId: voiceoverAssetId, gainDb, duckOriginal } } + : {}), + ...(outputLabel.trim().length > 0 ? { outputLabel: outputLabel.trim() } : {}), + lane, + }; + try { + const result = await api.post(`/video/${entry.videoAsset.id}/produce`, body); + const next: ProduceOutcome = { + job: result.job, + producedAssetId: result.producedAssetId, + outputKey: result.outputKey, + }; + setOutcome(next); + if (next.producedAssetId !== undefined) { + pollRef.current = setInterval(() => { + void api + .get(`/video/${next.producedAssetId}/status`) + .then(setProducedStatus) + .catch(() => undefined); + }, POLL_MS); + } + } catch (err) { + setSubmitError(errorMessage(err)); + } finally { + setSubmitting(false); + } + }; + + const producedInFlight = producedStatus !== null && IN_FLIGHT.includes(producedStatus.processingJob.state); + const producedComplete = producedStatus?.processingJob.state === "complete"; + const producedFailed = producedStatus?.processingJob.state === "failed"; + + return ( +
+

Producer

+
+ {!live &&

Offline — connect to the API to produce a variant.

} + + {/* Lower-third overlay */} +
+
+ + = LOWER_THIRD_MAX ? "text-warn" : "text-text-faint"}`} + > + {lowerThird.length}/{LOWER_THIRD_MAX} + +
+ setLowerThird(e.target.value)} + disabled={!live || !canManage} + /> +
+ + +
+
+ + {/* Logo — no asset picker exists in the video UI yet, so v1 takes the + object-storage key of an already-uploaded image directly. */} +
+ + setLogoKey(e.target.value)} + disabled={!live || !canManage} + /> +

+ No asset picker in the video UI yet — paste the storage key of an uploaded image. +

+
+ + {/* Voiceover */} +
+
+ Voiceover + + {phase === "recording" && } + {phase === "idle" ? (voiceoverAssetId !== null ? "uploaded" : "mic") : phase === "recording" ? "recording" : "ready"} + +
+ {micError !== null &&

{micError}

} +
+ {phase === "idle" && ( + + )} + {phase === "recording" && ( + + )} + {phase === "recorded" && ( + <> + + + + )} + {phase === "recorded" && recorded !== null && ( + + {formatDuration(recorded.durationMs)} · {formatBytes(recorded.sizeBytes)} + + )} +
+ {phase === "recorded" && previewUrl !== null && ( +
+ + {/* Mix */} +
+
+
+ + + {gainDb > 0 ? "+" : ""} + {gainDb} dB + +
+ setGainDb(Number(e.target.value))} + disabled={!live || !canManage} + className="w-full accent-accent" + /> +
+ −20 + +10 +
+
+ +
+ + {/* Output */} +
+ + setOutputLabel(e.target.value)} + disabled={!live || !canManage} + /> +
+ + +
+
+ +
+ + {voiceoverAssetId === null && lowerThird.trim().length === 0 && logoKey.trim().length === 0 && ( + Add a lower-third, logo, or voiceover first. + )} +
+ + {submitError !== null &&

{submitError}

} + + {outcome !== null && ( +
+
+ Produce job + + {outcome.job.state} + +
+ {outcome.job.error !== undefined &&

{outcome.job.error}

} + {producedComplete && producedStatus !== null && ( +

+ Produced {producedStatus.videoAsset.storageKey} — it is in the + library. +

+ )} + {producedFailed && ( +

+ The produced asset failed: {producedStatus?.processingJob.error ?? "see the job above."} +

+ )} + {producedInFlight && ( +

+ Rendering the produced asset… +

+ )} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/video/video-detail-drawer.tsx b/apps/web/src/components/video/video-detail-drawer.tsx index af788c7..833cf54 100644 --- a/apps/web/src/components/video/video-detail-drawer.tsx +++ b/apps/web/src/components/video/video-detail-drawer.tsx @@ -5,12 +5,14 @@ import { Badge, Button } from "../ui/primitives"; import { api, errorMessage } from "../../lib/api-client"; import type { VideoLibraryEntryDto, VideoProcessingState, VideoStatusDto } from "../../lib/video-types"; import { fileNameOf, formatBitrate, formatBytes, formatDuration, fpsLabel } from "./format"; +import { ProducerPanel } from "./producer-panel"; import { StatusChip } from "./status-chip"; /** * Right-hand detail drawer: probe metadata, derived bitrate, the processing - * job timeline, and where the asset has been published. Live assets poll - * GET /video/:id/status while their job is still in flight. + * job timeline, the producer panel (lower-third / voiceover / mix), and + * where the asset has been published. Live assets poll GET /video/:id/status + * while their job is still in flight. */ const PIPELINE: ReadonlyArray<{ state: VideoProcessingState; label: string }> = [ @@ -25,9 +27,13 @@ const IN_FLIGHT: VideoProcessingState[] = ["queued", "probing", "transcoding"]; export function VideoDetailDrawer({ entry, + live, + canManage, onClose, }: { entry: VideoLibraryEntryDto; + live: boolean; + canManage: boolean; onClose: () => void; }): React.ReactElement { const [status, setStatus] = useState(null); @@ -159,6 +165,8 @@ export function VideoDetailDrawer({ )} + +

Publications

{entry.publications.length === 0 ? ( diff --git a/apps/web/src/lib/media-types.ts b/apps/web/src/lib/media-types.ts new file mode 100644 index 0000000..a58bf8a --- /dev/null +++ b/apps/web/src/lib/media-types.ts @@ -0,0 +1,55 @@ +/** + * Wire shapes + helpers for the media asset endpoints (POST /media, GET /media/:id). + * + * Kept local to the web app for the same reason as submissions-types.ts: the + * shared api-types.ts may be edited by other builders in parallel while the + * media backend lands. Hand-synced with the media controller contract. + */ + +import { API_URL, ApiError, ApiUnreachableError, apiUploadFile } from "./api-client"; +import { getSessionToken } from "./session-token"; +import type { DraftPost } from "./api-types"; + +/** POST /media (multipart field "file") → the stored asset. */ +export interface MediaAssetRecord { + id: string; + url: string; +} + +/** Mastodon's attachment cap: up to 4 images, or a single video on its own. */ +export const MEDIA_MAX_ATTACHMENTS = 4; + +/** Shown next to the attach controls so the cap is discoverable before hitting it. */ +export const MEDIA_ATTACH_HINT = "Mastodon allows up to 4 images or 1 video"; + +/** + * Bytes for GET /media/:id are auth-scoped, so a plain (which cannot + * send the Authorization header) does not work — mirror downloadGroupFile in + * components/files/files-client.ts: replay the client's auth rules, stream the + * body into a Blob and hand back an object URL the caller must revoke. + */ +export async function fetchMediaObjectUrl(assetId: string): Promise { + const token = getSessionToken(); + let response: Response; + try { + response = await fetch(`${API_URL}/media/${encodeURIComponent(assetId)}`, { + credentials: "include", + headers: token === null ? {} : { Authorization: `Bearer ${token}` }, + }); + } catch (err) { + throw new ApiUnreachableError(err); + } + if (!response.ok) { + if (response.status === 401) throw new ApiError(401, "Your session expired — sign in again.", null); + throw new ApiError(response.status, `Could not load media (HTTP ${response.status})`, null); + } + return URL.createObjectURL(await response.blob()); +} + +/** Multipart upload with browser progress; apiUploadFile owns auth + error shape. */ +export function uploadMediaAsset(file: File, onProgress: (percent: number) => void): Promise { + return apiUploadFile("/media", file, onProgress); +} + +/** drafts accept altTexts aligned by index with mediaAssetIds; null = no alt written yet. */ +export type MediaDraftPost = DraftPost & { altTexts?: (string | null)[] }; diff --git a/apps/web/src/lib/video-types.ts b/apps/web/src/lib/video-types.ts index a518b12..4d318fc 100644 --- a/apps/web/src/lib/video-types.ts +++ b/apps/web/src/lib/video-types.ts @@ -55,3 +55,44 @@ export interface VideoStatusDto { videoAsset: VideoAssetDto; processingJob: VideoProcessingJobDto; } + +/** + * Producer (POST /video/:id/produce) request/response shapes — same + * hand-synced standing as the routes above. + */ + +/** Where a lower-third overlay sits in the frame. */ +export type VideoOverlayPosition = "bottom-left" | "bottom-center" | "bottom-right"; + +/** One overlay in a produce request; v1 renders lower-thirds only. */ +export interface VideoOverlayDto { + kind: "lower-third"; + text: string; + position: VideoOverlayPosition; +} + +/** Voiceover mix settings; assetId is an asset already in the workspace. */ +export interface VideoVoiceoverDto { + assetId: string; + gainDb: number; + duckOriginal: boolean; +} + +/** Where the produced variant is rendered. */ +export type VideoProduceLane = "local" | "remote"; + +/** Body of POST /video/:id/produce. */ +export interface VideoProduceRequest { + overlays?: VideoOverlayDto[]; + voiceover?: VideoVoiceoverDto; + outputLabel?: string; + lane?: VideoProduceLane; +} + +/** Response of POST /video/:id/produce — the job that renders the derived asset. */ +export interface VideoProduceResultDto { + job: VideoProcessingJobDto; + /** The derived asset the job writes; it appears in the library when the job completes. */ + producedAssetId?: string; + outputKey?: string; +}