feat: compose media attachments UI + video producer panel (t2+t5)
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.
This commit is contained in:
parent
71db0a08b2
commit
1183d69849
7 changed files with 903 additions and 17 deletions
|
|
@ -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<Array<{ name: string; url: string; alt: string }>>([]);
|
||||
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<string, unknown>): DraftPost {
|
||||
function postFor(providerMetadata: Record<string, unknown>): 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 {
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div className="min-h-[220px] flex-1 overflow-y-auto rounded-lg border border-border-subtle bg-surface p-4 scrollbar-thin">
|
||||
<div
|
||||
onDragEnter={(event) => { 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 && <div aria-hidden className="pointer-events-none absolute inset-2 z-10 flex items-center justify-center rounded-md border border-dashed border-accent/60 bg-surface/80 text-xs text-accent">Drop to attach</div>}
|
||||
<EditorContent editor={editor} className="prose-invert max-w-none text-sm [&_.ProseMirror]:min-h-[180px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-text-faint [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 lg:hidden">
|
||||
<div className="flex items-center gap-3"><div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-4 text-xs font-bold ${over ? "border-danger text-danger" : chars >= limit * 0.9 ? "border-warn text-warn" : "border-accent text-accent"}`}>{Math.max(0, limit - chars)}</div><div className="min-w-0 flex-1 overflow-x-auto"><div className="flex gap-2">{selected.map((target) => { const max = PLATFORMS[target.provider]?.charLimit ?? 500; return <button key={target.key} type="button" onClick={() => setPreviewOpen(true)} className={`min-h-11 shrink-0 rounded-md border px-3 text-xs ${chars > max ? "border-danger text-danger" : "border-border text-text-dim"}`}>{PLATFORMS[target.provider]?.displayName ?? target.provider} {chars}/{max}</button>; })}</div></div></div>
|
||||
{media.length > 0 && <div className="mt-2 flex gap-2 overflow-x-auto">{media.map((item) => <div key={item.url} className="w-28 shrink-0 rounded-md border border-border p-2"><div className="h-16 overflow-hidden rounded bg-surface-2"><img src={item.url} alt={item.alt || item.name} className="h-full w-full object-cover" /></div><input aria-label={`Alt text for ${item.name}`} value={item.alt} onChange={(event) => 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" /><span className={`text-2xs ${item.alt ? "text-live" : "text-warn"}`}>{item.alt ? "ALT ready" : "Add ALT"}</span><button type="button" className="min-h-11 w-full text-2xs text-danger" onClick={() => { URL.revokeObjectURL(item.url); setMedia((current) => current.filter((value) => value.url !== item.url)); }}>Remove</button></div>)}</div>}
|
||||
{mediaBlocked && <p className="mt-2 text-xs text-warn">Media upload is not available in the publishing API yet. Remove previews to save or post text.</p>}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<label className={`min-h-11 cursor-pointer rounded-md border border-border px-3 text-2xs leading-[44px] text-text-dim transition-colors duration-150 hover:border-accent hover:text-accent ${media.full ? "pointer-events-none opacity-40" : ""}`}>
|
||||
+ Attach media
|
||||
<input type="file" multiple accept="image/*,video/*" className="hidden" disabled={media.full} onChange={(event) => { if (event.target.files !== null) media.addFiles(event.target.files); event.target.value = ""; }} />
|
||||
</label>
|
||||
<span className="text-2xs text-text-faint">{media.attachments.length}/{MEDIA_MAX_ATTACHMENTS} · {MEDIA_ATTACH_HINT}</span>
|
||||
</div>
|
||||
<AttachmentsRow attachments={media.attachments} onAltChange={media.setAlt} onRemove={media.remove} onRetry={media.retry} />
|
||||
{media.warning !== null && <p className="mt-2 text-2xs text-warn">{media.warning}</p>}
|
||||
<button type="button" onClick={() => setScheduleOpen(true)} className="mt-3 flex min-h-12 w-full items-center justify-between border-b border-border-subtle text-left text-sm"><span>Schedule</span><span className="text-xs text-text-faint">{scheduledAt ? new Date(scheduledAt).toLocaleString() : "Post now"} ›</span></button>
|
||||
<label className="flex min-h-12 items-center justify-between border-b border-border-subtle text-sm"><span>Request approval</span><input type="checkbox" className="h-6 w-6 accent-accent" checked={approval === "review" || !can("approval.grant")} disabled={!can("approval.grant")} onChange={(event) => setApproval(event.target.checked ? "review" : "draft")} /></label>
|
||||
{!can("approval.grant") && <p className="mt-1 text-2xs text-text-faint">Your role submits posts for editor approval.</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 hidden flex-wrap items-center gap-3 lg:flex">
|
||||
<label className={`cursor-pointer rounded-md border border-border px-2 py-1 text-xs text-text-dim transition-colors duration-150 hover:border-accent hover:text-accent ${media.full ? "pointer-events-none opacity-40" : ""}`}>
|
||||
+ Attach
|
||||
<input type="file" multiple accept="image/*,video/*" className="hidden" disabled={media.full} onChange={(event) => { if (event.target.files !== null) media.addFiles(event.target.files); event.target.value = ""; }} />
|
||||
</label>
|
||||
<span className="text-2xs text-text-faint">{media.attachments.length}/{MEDIA_MAX_ATTACHMENTS} · {MEDIA_ATTACH_HINT}</span>
|
||||
<span className={`font-mono text-xs ${over ? "text-danger" : "text-text-faint"}`}>
|
||||
{chars} / {limit}
|
||||
</span>
|
||||
|
|
@ -252,18 +275,24 @@ export default function ComposePage(): React.ReactElement {
|
|||
/>
|
||||
</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="outline" disabled={busy || chars === 0} onClick={() => void saveDraft()}>Save draft</Button>
|
||||
<Button variant="primary" disabled={busy || targets.length === 0 || chars === 0 || over} onClick={() => void publish()}>
|
||||
<Button variant="outline" disabled={busy || mediaBusy || chars === 0} onClick={() => void saveDraft()}>Save draft</Button>
|
||||
<Button variant="primary" disabled={busy || mediaBusy || targets.length === 0 || chars === 0 || over} onClick={() => void publish()}>
|
||||
{scheduledAt !== "" ? "Schedule" : approval === "review" ? "Submit" : "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{mediaBusy && (
|
||||
<p className="mt-2 text-xs text-accent">Uploading media… {media.attachments.filter((item) => item.assetId !== null && item.error === null).length}/{media.attachments.filter((item) => item.error === null).length} ready</p>
|
||||
)}
|
||||
{mediaBroken && !mediaBusy && (
|
||||
<p className="mt-2 text-xs text-warn">An attachment failed to upload — retry or remove it before posting.</p>
|
||||
)}
|
||||
{notice !== null && (
|
||||
<p className={`mt-2 text-xs ${notice.tone === "ok" ? "text-live" : "text-danger"}`}>{notice.text}</p>
|
||||
)}
|
||||
<div className="fixed left-0 right-0 z-30 border-t border-border bg-surface px-4 py-2 lg:hidden" style={{ bottom: `calc(var(--mobile-tab-height) + var(--mobile-safe-bottom) + ${keyboardOffset}px)` }}>
|
||||
<p className="mb-2 truncate text-2xs text-text-faint">{approval === "review" || !can("approval.grant") ? "→ Editors approve" : "→ Ready to publish"} · {scheduledAt ? `publishes ${new Date(scheduledAt).toLocaleString()}` : "post now"} · {selected.length} accounts</p>
|
||||
<div className="flex gap-2"><Button variant="ghost" className="min-h-11" onClick={() => router.back()}>Cancel</Button><Button variant="outline" className="min-h-11 flex-1" disabled={busy || chars === 0 || mediaBlocked} onClick={() => void saveDraft()}>Draft</Button><Button variant="primary" className="min-h-11 flex-1" disabled={busy || selected.length === 0 || chars === 0 || over || mediaBlocked} onClick={() => void publish()}>{scheduledAt ? "Schedule" : approval === "review" || !can("approval.grant") ? "Submit" : "Post"}</Button></div>
|
||||
<div className="flex gap-2"><Button variant="ghost" className="min-h-11" onClick={() => router.back()}>Cancel</Button><Button variant="outline" className="min-h-11 flex-1" disabled={busy || mediaBusy || chars === 0} onClick={() => void saveDraft()}>Draft</Button><Button variant="primary" className="min-h-11 flex-1" disabled={busy || selected.length === 0 || chars === 0 || over || mediaBusy} onClick={() => void publish()}>{scheduledAt ? "Schedule" : approval === "review" || !can("approval.grant") ? "Submit" : "Post"}</Button></div>
|
||||
</div>
|
||||
<Sheet open={scheduleOpen} onClose={() => setScheduleOpen(false)} title="Schedule post" footer={<Button variant="primary" className="min-h-11 w-full justify-center" onClick={() => setScheduleOpen(false)}>Done</Button>}><label className="block text-xs text-text-faint" htmlFor="mobile-schedule">Date and time</label><Input id="mobile-schedule" type="datetime-local" value={scheduledAt} onChange={(event) => setScheduledAt(event.target.value)} className="mt-2 min-h-12" /><div className="mt-4 flex gap-2">{[1, 24, 48].map((hours) => <button key={hours} type="button" className="min-h-11 rounded-md border border-border px-3 text-xs" onClick={() => { const date = new Date(Date.now() + hours * 3600000); setScheduledAt(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`); }}>{hours === 1 ? "In 1 hour" : hours === 24 ? "Tomorrow" : "In 2 days"}</button>)}</div><button type="button" onClick={() => setScheduledAt("")} className="mt-4 min-h-11 text-xs text-text-faint">Clear schedule</button></Sheet>
|
||||
<Sheet open={previewOpen} onClose={() => setPreviewOpen(false)} title="Target previews">{selected.map((target) => <div key={target.key} className="mb-3 rounded-md border border-border p-3"><p className="text-xs text-text-faint">{target.label}</p><p className="mt-2 whitespace-pre-wrap text-sm">{editor?.getText() || "Your post preview appears here…"}</p></div>)}</Sheet>
|
||||
|
|
|
|||
|
|
@ -229,7 +229,15 @@ export default function VideoPage(): React.ReactElement {
|
|||
rendering and transcoding run separately in the media worker.
|
||||
</p>
|
||||
|
||||
{selected !== null && <VideoDetailDrawer key={selected.videoAsset.id} entry={selected} onClose={() => setSelectedId(null)} />}
|
||||
{selected !== null && (
|
||||
<VideoDetailDrawer
|
||||
key={selected.videoAsset.id}
|
||||
entry={selected}
|
||||
live={live}
|
||||
canManage={canManage}
|
||||
onClose={() => setSelectedId(null)}
|
||||
/>
|
||||
)}
|
||||
{confirmDelete !== null && (
|
||||
<DeleteVideoModal
|
||||
asset={confirmDelete.videoAsset}
|
||||
|
|
|
|||
235
apps/web/src/components/compose/attachments.tsx
Normal file
235
apps/web/src/components/compose/attachments.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { errorMessage } from "../../lib/api-client";
|
||||
import { MEDIA_MAX_ATTACHMENTS, fetchMediaObjectUrl, uploadMediaAsset } from "../../lib/media-types";
|
||||
|
||||
/**
|
||||
* One picked file on its way to POST /media. `previewUrl` is the asset's bytes
|
||||
* fetched with auth and rewrapped as an object URL (never a bare asset URL,
|
||||
* which the browser cannot authorize) — revoke it on remove/unmount.
|
||||
*/
|
||||
export interface ComposeAttachment {
|
||||
key: string;
|
||||
file: File;
|
||||
name: string;
|
||||
kind: "image" | "video";
|
||||
progress: number;
|
||||
assetId: string | null;
|
||||
previewUrl: string | null;
|
||||
alt: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function kindOf(mime: string): "image" | "video" | null {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Attach state + upload lifecycle for the compose surface. */
|
||||
export function useComposeAttachments(): {
|
||||
attachments: ComposeAttachment[];
|
||||
warning: string | null;
|
||||
uploading: boolean;
|
||||
full: boolean;
|
||||
mediaAssetIds: string[];
|
||||
altTexts: Array<string | null>;
|
||||
addFiles: (files: FileList | File[]) => void;
|
||||
setAlt: (key: string, alt: string) => void;
|
||||
remove: (key: string) => void;
|
||||
retry: (key: string) => void;
|
||||
} {
|
||||
const [attachments, setAttachments] = useState<ComposeAttachment[]>([]);
|
||||
const [warning, setWarning] = useState<string | null>(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<ComposeAttachment>): void => {
|
||||
setAttachments((current) => current.map((item) => (item.key === key ? { ...item, ...changes } : item)));
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(
|
||||
async (key: string, file: File): Promise<void> => {
|
||||
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 (
|
||||
<div className="mt-2 flex gap-2 overflow-x-auto">
|
||||
{attachments.map((item) => {
|
||||
const pending = item.assetId === null && item.error === null;
|
||||
return (
|
||||
<div key={item.key} className="w-32 shrink-0 rounded-md border border-border p-2">
|
||||
<div className="relative h-16 overflow-hidden rounded bg-surface-2">
|
||||
{item.previewUrl !== null && item.kind === "image" && (
|
||||
<img src={item.previewUrl} alt={item.alt || item.name} className="h-full w-full object-cover" />
|
||||
)}
|
||||
{item.previewUrl !== null && item.kind === "video" && (
|
||||
<video src={item.previewUrl} muted playsInline preload="metadata" className="h-full w-full object-cover" />
|
||||
)}
|
||||
{item.previewUrl === null && !pending && (
|
||||
<span className="flex h-full items-center justify-center px-1 text-center text-2xs text-text-faint">
|
||||
{item.error !== null ? "upload failed" : "no preview"}
|
||||
</span>
|
||||
)}
|
||||
{pending && (
|
||||
<span className="absolute inset-x-1 bottom-1 block">
|
||||
<span className="block h-1 overflow-hidden rounded bg-surface">
|
||||
<span className="block h-full bg-accent transition-[width] duration-150" style={{ width: `${item.progress}%` }} />
|
||||
</span>
|
||||
<span className="mt-0.5 block text-center text-2xs text-text-faint">{item.progress}%</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
aria-label={`Alt text for ${item.name}`}
|
||||
value={item.alt}
|
||||
disabled={pending}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<span className={`text-2xs ${item.alt ? "text-live" : "text-warn"}`}>{item.alt ? "ALT ready" : "Add ALT"}</span>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
{item.error !== null ? (
|
||||
<button type="button" className="min-h-11 text-2xs text-accent" onClick={() => onRetry(item.key)}>
|
||||
Retry
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-2xs text-text-faint">{item.kind}</span>
|
||||
)}
|
||||
<button type="button" className="min-h-11 text-2xs text-danger" onClick={() => onRemove(item.key)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
510
apps/web/src/components/video/producer-panel.tsx
Normal file
510
apps/web/src/components/video/producer-panel.tsx
Normal file
|
|
@ -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<VideoProduceLane>("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<string | null>(null);
|
||||
const [phase, setPhase] = useState<RecorderPhase>("idle");
|
||||
const [recorded, setRecorded] = useState<VoiceoverDraft | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadPercent, setUploadPercent] = useState(0);
|
||||
const [voiceoverAssetId, setVoiceoverAssetId] = useState<string | null>(null);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [outcome, setOutcome] = useState<ProduceOutcome | null>(null);
|
||||
const [producedStatus, setProducedStatus] = useState<VideoStatusDto | null>(null);
|
||||
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const recordedAtRef = useRef(0);
|
||||
const previewTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | 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<void> => {
|
||||
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<VideoStatusDto>("/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<void> => {
|
||||
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<VideoProduceResultDto>(`/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<VideoStatusDto>(`/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 (
|
||||
<section>
|
||||
<h3 className="mb-2 text-2xs font-medium uppercase tracking-wide text-text-faint">Producer</h3>
|
||||
<div className="space-y-3 rounded-lg border border-border-subtle bg-surface-2 p-3">
|
||||
{!live && <p className="text-2xs text-text-faint">Offline — connect to the API to produce a variant.</p>}
|
||||
|
||||
{/* Lower-third overlay */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<label htmlFor="producer-lower-third" className="text-xs text-text-dim">
|
||||
Lower-third
|
||||
</label>
|
||||
<span
|
||||
className={`font-mono text-2xs ${lowerThird.length >= LOWER_THIRD_MAX ? "text-warn" : "text-text-faint"}`}
|
||||
>
|
||||
{lowerThird.length}/{LOWER_THIRD_MAX}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
id="producer-lower-third"
|
||||
value={lowerThird}
|
||||
maxLength={LOWER_THIRD_MAX}
|
||||
placeholder="e.g. Maya Okafor — Field Producer"
|
||||
onChange={(e) => setLowerThird(e.target.value)}
|
||||
disabled={!live || !canManage}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<label htmlFor="producer-position" className="text-2xs text-text-faint">
|
||||
Position
|
||||
</label>
|
||||
<select
|
||||
id="producer-position"
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value)}
|
||||
disabled={!live || !canManage}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
|
||||
>
|
||||
{POSITION_CHOICES.map((choice) => (
|
||||
<option key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo — no asset picker exists in the video UI yet, so v1 takes the
|
||||
object-storage key of an already-uploaded image directly. */}
|
||||
<div>
|
||||
<label htmlFor="producer-logo" className="mb-1 block text-xs text-text-dim">
|
||||
Logo (storage key)
|
||||
</label>
|
||||
<Input
|
||||
id="producer-logo"
|
||||
value={logoKey}
|
||||
placeholder="e.g. logos/superposter-wordmark.png"
|
||||
onChange={(e) => setLogoKey(e.target.value)}
|
||||
disabled={!live || !canManage}
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-text-faint">
|
||||
No asset picker in the video UI yet — paste the storage key of an uploaded image.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Voiceover */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-text-dim">Voiceover</span>
|
||||
<Badge tone={phase === "recording" ? "danger" : "neutral"}>
|
||||
{phase === "recording" && <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-current" />}
|
||||
{phase === "idle" ? (voiceoverAssetId !== null ? "uploaded" : "mic") : phase === "recording" ? "recording" : "ready"}
|
||||
</Badge>
|
||||
</div>
|
||||
{micError !== null && <p className="mb-1 text-2xs text-danger">{micError}</p>}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{phase === "idle" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!live || !canManage || !recorderSupported || uploading}
|
||||
onClick={startRecording}
|
||||
>
|
||||
Record
|
||||
</Button>
|
||||
)}
|
||||
{phase === "recording" && (
|
||||
<Button size="sm" variant="danger" onClick={stopRecording}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
{phase === "recorded" && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!live || !canManage || uploading}
|
||||
onClick={() => {
|
||||
setRecorded(null);
|
||||
setPhase("idle");
|
||||
startRecording();
|
||||
}}
|
||||
>
|
||||
Re-take
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
disabled={!live || !canManage || uploading}
|
||||
onClick={() => void uploadVoiceover()}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{phase === "recorded" && recorded !== null && (
|
||||
<span className="text-2xs text-text-faint">
|
||||
{formatDuration(recorded.durationMs)} · {formatBytes(recorded.sizeBytes)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{phase === "recorded" && previewUrl !== null && (
|
||||
<audio src={previewUrl} controls className="mt-2 h-8 w-full" />
|
||||
)}
|
||||
{uploading && (
|
||||
<div className="mt-2 flex items-center gap-2 text-2xs text-text-dim">
|
||||
<Loading size={14} label="Uploading voiceover" />
|
||||
Uploading voiceover {uploadPercent}%…
|
||||
</div>
|
||||
)}
|
||||
{voiceoverAssetId !== null && (
|
||||
<p className="mt-2 text-2xs text-live">
|
||||
Voiceover uploaded — asset <span className="font-mono">{voiceoverAssetId}</span>
|
||||
</p>
|
||||
)}
|
||||
{recorded === null && voiceoverAssetId === null && (
|
||||
<p className="mt-1 text-2xs leading-relaxed text-text-faint">
|
||||
{recorderSupported
|
||||
? "Record a take in the browser (WebM/Opus), then upload it through the video upload endpoint."
|
||||
: "This browser cannot record audio — upload a voiceover file from the library instead."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mix */}
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-2">
|
||||
<label htmlFor="producer-gain" className="text-xs text-text-dim">
|
||||
Voiceover gain
|
||||
</label>
|
||||
<span className="font-mono text-2xs text-text-dim">
|
||||
{gainDb > 0 ? "+" : ""}
|
||||
{gainDb} dB
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="producer-gain"
|
||||
type="range"
|
||||
min={GAIN_MIN_DB}
|
||||
max={GAIN_MAX_DB}
|
||||
step={1}
|
||||
value={gainDb}
|
||||
onChange={(e) => setGainDb(Number(e.target.value))}
|
||||
disabled={!live || !canManage}
|
||||
className="w-full accent-accent"
|
||||
/>
|
||||
<div className="flex justify-between font-mono text-2xs text-text-faint">
|
||||
<span>−20</span>
|
||||
<span>+10</span>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-text-dim">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={duckOriginal}
|
||||
onChange={(e) => setDuckOriginal(e.target.checked)}
|
||||
disabled={!live || !canManage}
|
||||
className="accent-accent"
|
||||
/>
|
||||
Duck the original audio under the voiceover
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<div>
|
||||
<label htmlFor="producer-output" className="mb-1 block text-xs text-text-dim">
|
||||
Output label
|
||||
</label>
|
||||
<Input
|
||||
id="producer-output"
|
||||
value={outputLabel}
|
||||
placeholder="e.g. cutdown-30s"
|
||||
onChange={(e) => setOutputLabel(e.target.value)}
|
||||
disabled={!live || !canManage}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<label htmlFor="producer-lane" className="text-2xs text-text-faint">
|
||||
Lane
|
||||
</label>
|
||||
<select
|
||||
id="producer-lane"
|
||||
value={lane}
|
||||
onChange={(e) => setLane(e.target.value as VideoProduceLane)}
|
||||
disabled={!live || !canManage}
|
||||
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs"
|
||||
>
|
||||
{LANE_CHOICES.map((choice) => (
|
||||
<option key={choice.value} value={choice.value}>
|
||||
{choice.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="primary" disabled={!canSubmit} onClick={() => void submit()}>
|
||||
{submitting ? "Submitting…" : "Submit"}
|
||||
</Button>
|
||||
{voiceoverAssetId === null && lowerThird.trim().length === 0 && logoKey.trim().length === 0 && (
|
||||
<span className="text-2xs text-text-faint">Add a lower-third, logo, or voiceover first.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{submitError !== null && <p className="text-2xs text-danger">{submitError}</p>}
|
||||
|
||||
{outcome !== null && (
|
||||
<div className="space-y-1.5 rounded-md border border-border-subtle bg-surface p-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-text-dim">Produce job</span>
|
||||
<Badge tone={outcome.job.state === "complete" ? "live" : outcome.job.state === "failed" ? "danger" : "accent"}>
|
||||
{outcome.job.state}
|
||||
</Badge>
|
||||
</div>
|
||||
{outcome.job.error !== undefined && <p className="text-2xs text-danger">{outcome.job.error}</p>}
|
||||
{producedComplete && producedStatus !== null && (
|
||||
<p className="text-2xs text-live">
|
||||
Produced <span className="font-mono">{producedStatus.videoAsset.storageKey}</span> — it is in the
|
||||
library.
|
||||
</p>
|
||||
)}
|
||||
{producedFailed && (
|
||||
<p className="text-2xs text-danger">
|
||||
The produced asset failed: {producedStatus?.processingJob.error ?? "see the job above."}
|
||||
</p>
|
||||
)}
|
||||
{producedInFlight && (
|
||||
<p className="flex items-center gap-2 text-2xs text-text-dim">
|
||||
<Loading size={14} label="Producing" /> Rendering the produced asset…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<VideoStatusDto | null>(null);
|
||||
|
|
@ -159,6 +165,8 @@ export function VideoDetailDrawer({
|
|||
)}
|
||||
</section>
|
||||
|
||||
<ProducerPanel entry={entry} live={live} canManage={canManage} />
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-2xs font-medium uppercase tracking-wide text-text-faint">Publications</h3>
|
||||
{entry.publications.length === 0 ? (
|
||||
|
|
|
|||
55
apps/web/src/lib/media-types.ts
Normal file
55
apps/web/src/lib/media-types.ts
Normal file
|
|
@ -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 <img src> (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<string> {
|
||||
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<MediaAssetRecord> {
|
||||
return apiUploadFile<MediaAssetRecord>("/media", file, onProgress);
|
||||
}
|
||||
|
||||
/** drafts accept altTexts aligned by index with mediaAssetIds; null = no alt written yet. */
|
||||
export type MediaDraftPost = DraftPost & { altTexts?: (string | null)[] };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue