* Export videos for named platforms and check each file against its limits Exports only knew aspect ratios, so a cut could still be rejected or degraded on upload: too long for the platform, over its size limit, or far off its loudness. export_video now takes platforms (TikTok, Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels, LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the presets. Each platform is rendered to its frame with the export's fit, its frame rate cap, a bitrate cap that also keeps the file under the size limit, and its loudness target in one loudnorm pass. Every render is probed and measured afterwards. A file over the size limit is encoded once more at a bitrate scaled from what it averaged, and audio more than 2 LU off the target gets one linear loudness pass. The result is stored on the output as a pass, warn or fail check with language-neutral issue codes. A platform the video is too long or too short for is not rendered: the card lists it with the reason, the other targets still render, and the bot is told to trim first. The limits come from the earlier profile research, with its sources, review date and unverified flag; content, disclosure and caption rules are left out because they need inference or a review flow. Preset definitions move to their own module so the platform schemas can use their aspect ratios without an import cycle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * Offer platforms in the media card's export menu and show their checks The card's Export menu now lists the platforms first, by brand name and untranslated, then the aspect ratios after a separator; the platform an output was made for is left out, like its own shape. A platform export shows the platform's name under the player, or, when its check did not pass, one line for the first issue instead ("Too long for TikTok (max 10:00)"), in the warning or destructive color. Skipped platforms get the same kind of line, and they stand in for the error of a job that skipped every platform, so the card does not say the same thing twice in two languages. The E2E spec seeds a platform export with a failed check and a skipped platform, and captures the card and the open menu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(media): keep one skipped entry per platform on the card A job that recovers after a crash runs again and reports the same skipped platforms; the newest reason per platform now replaces the earlier one instead of listing it twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude <noreply@anthropic.com>
446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
import { useLingui } from "@lingui/react/macro";
|
|
import type {
|
|
MediaJobBlock,
|
|
MediaJobStatus,
|
|
MediaOperation,
|
|
MediaOutput,
|
|
MediaPlatformId,
|
|
MediaPresetId,
|
|
} from "@rakazo/contracts";
|
|
import { MEDIA_JOB_ACTIVE_STATUSES, MEDIA_PRESETS, MediaPresetIdSchema } from "@rakazo/contracts";
|
|
import {
|
|
Button,
|
|
buttonVariants,
|
|
cn,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@rakazo/ui-web";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import { Captions, ChevronDown, Download, Film, ImageIcon, Scissors, Sparkles } from "lucide-react";
|
|
import { useEffect, useId, useState } from "react";
|
|
import type { ArtifactTarget } from "../../lib/artifact-open";
|
|
import { artifactUrl } from "../../lib/artifact-url";
|
|
import { rpc } from "../../lib/rpc";
|
|
import { ArtifactImage } from "../../pages/shell/message-cards";
|
|
import { ArtifactFileCard } from "../ArtifactFileCard";
|
|
import { Shimmer } from "../ai/primitives";
|
|
import { MediaPlayer } from "./MediaPlayer";
|
|
import { exportPlatformsFor, platformIssueLine, platformName } from "./media-platforms";
|
|
|
|
const CARD_CLASS =
|
|
"w-[min(480px,96%)] rounded-[18px] border border-border bg-card/75 p-3.5 text-start shadow-xs";
|
|
|
|
export type MediaCardAction =
|
|
| { kind: "export"; artifactId: string; preset: MediaPresetId }
|
|
| { kind: "export"; artifactId: string; platform: MediaPlatformId }
|
|
| { kind: "transcribe"; artifactId: string; name: string }
|
|
| { kind: "cancel" };
|
|
|
|
/** Which follow-up jobs the space can run; actions it cannot run are not offered. */
|
|
export interface MediaCardAvailability {
|
|
export: boolean;
|
|
transcribe: boolean;
|
|
}
|
|
|
|
const NOTHING_AVAILABLE: MediaCardAvailability = { export: false, transcribe: false };
|
|
|
|
const OPERATION_ICONS: Record<MediaOperation, LucideIcon> = {
|
|
generate_image: Sparkles,
|
|
generate_video: Sparkles,
|
|
edit_image: ImageIcon,
|
|
edit_video: Scissors,
|
|
export_video: Film,
|
|
transcribe: Captions,
|
|
};
|
|
|
|
export function isActiveMediaStatus(status: MediaJobStatus): boolean {
|
|
return (MEDIA_JOB_ACTIVE_STATUSES as readonly MediaJobStatus[]).includes(status);
|
|
}
|
|
|
|
/** Formats a video can be exported to: every preset except the shape it already has. */
|
|
export function exportPresetsFor(output: Pick<MediaOutput, "width" | "height" | "preset">) {
|
|
const ratio = output.width && output.height ? output.width / output.height : null;
|
|
return MediaPresetIdSchema.options.filter((id) => {
|
|
if (id === output.preset) return false;
|
|
const preset = MEDIA_PRESETS[id];
|
|
return ratio === null || Math.abs(preset.width / preset.height - ratio) > 0.01;
|
|
});
|
|
}
|
|
|
|
function StatusLabel({ status }: { status: MediaJobStatus }) {
|
|
const { t } = useLingui();
|
|
if (status === "running") {
|
|
return (
|
|
<span className="text-ui-body-sm font-medium">
|
|
<Shimmer>{t`Working…`}</Shimmer>
|
|
</span>
|
|
);
|
|
}
|
|
const label =
|
|
status === "queued"
|
|
? t`Queued`
|
|
: status === "waiting"
|
|
? t`Waiting for GPU`
|
|
: status === "done"
|
|
? t`Done`
|
|
: status === "failed"
|
|
? t`Failed`
|
|
: t`Cancelled`;
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"text-ui-body-sm font-medium",
|
|
status === "done"
|
|
? "text-success"
|
|
: status === "failed"
|
|
? "text-destructive"
|
|
: "text-muted-foreground",
|
|
)}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function OutputActions({
|
|
output,
|
|
available,
|
|
busy,
|
|
onAction,
|
|
}: {
|
|
output: MediaOutput;
|
|
available: MediaCardAvailability;
|
|
busy: boolean;
|
|
onAction?: (action: MediaCardAction) => void;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const name = output.name;
|
|
const platforms = output.kind === "video" ? exportPlatformsFor(output) : [];
|
|
const presets = output.kind === "video" ? exportPresetsFor(output) : [];
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<a
|
|
href={artifactUrl(output.artifactId, { download: true })}
|
|
download={name}
|
|
aria-label={t`Download ${name}`}
|
|
title={t`Download ${name}`}
|
|
className={cn(
|
|
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
|
"text-muted-foreground",
|
|
)}
|
|
>
|
|
<Download />
|
|
</a>
|
|
{onAction && available.export && platforms.length + presets.length > 0 ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
disabled={busy}
|
|
render={<Button variant="outline" size="sm" data-testid="media-export" />}
|
|
>
|
|
<Film />
|
|
{t`Export`}
|
|
<ChevronDown />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-auto min-w-24">
|
|
{platforms.map((platform) => (
|
|
<DropdownMenuItem
|
|
key={platform}
|
|
onClick={() =>
|
|
onAction({ kind: "export", artifactId: output.artifactId, platform })
|
|
}
|
|
>
|
|
{platformName(platform)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
{platforms.length > 0 && presets.length > 0 ? <DropdownMenuSeparator /> : null}
|
|
{presets.map((preset) => (
|
|
<DropdownMenuItem
|
|
key={preset}
|
|
onClick={() => onAction({ kind: "export", artifactId: output.artifactId, preset })}
|
|
>
|
|
{MEDIA_PRESETS[preset].aspect}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
{onAction && available.transcribe && (output.kind === "video" || output.kind === "audio") ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
onAction({ kind: "transcribe", artifactId: output.artifactId, name: output.name })
|
|
}
|
|
>
|
|
<Captions />
|
|
{t`Transcribe`}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The platform an output was exported for, or its first problem there. */
|
|
function PlatformLine({ output }: { output: MediaOutput }) {
|
|
if (!output.platform) return null;
|
|
const issue = output.check?.status === "pass" ? undefined : output.check?.issues[0];
|
|
return (
|
|
<p
|
|
className={cn(
|
|
"text-ui-body-sm",
|
|
!issue
|
|
? "text-muted-foreground"
|
|
: output.check?.status === "fail"
|
|
? "text-destructive"
|
|
: "text-warning",
|
|
)}
|
|
>
|
|
{issue ? platformIssueLine(output.platform, issue) : platformName(output.platform)}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The live card of a media job: what it makes, how far it is, and the results with the
|
|
* follow-ups that fit them (export other formats, transcribe). Nothing else is shown.
|
|
*/
|
|
export function MediaCardView({
|
|
block,
|
|
available = NOTHING_AVAILABLE,
|
|
onAction,
|
|
}: {
|
|
block: MediaJobBlock;
|
|
available?: MediaCardAvailability;
|
|
/** Starts a follow-up job or cancels this one; rejects with a message to show. */
|
|
onAction?: (action: MediaCardAction) => Promise<void>;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const titleId = useId();
|
|
const [busy, setBusy] = useState(false);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const active = isActiveMediaStatus(block.status);
|
|
const Icon = OPERATION_ICONS[block.operation];
|
|
const percent = Math.round((block.progress ?? 0) * 100);
|
|
const images = block.outputs.filter((output) => output.kind === "image");
|
|
const others = block.outputs.filter((output) => output.kind !== "image");
|
|
const skipped = block.skipped ?? [];
|
|
// When nothing was rendered because every platform was skipped, the skipped lines say why.
|
|
const showError =
|
|
block.status === "failed" && block.error && !(skipped.length && !block.outputs.length);
|
|
|
|
const act = onAction
|
|
? (action: MediaCardAction) => {
|
|
setBusy(true);
|
|
setActionError(null);
|
|
void onAction(action)
|
|
.catch((error: unknown) =>
|
|
setActionError(error instanceof Error && error.message ? error.message : t`Failed`),
|
|
)
|
|
.finally(() => setBusy(false));
|
|
}
|
|
: undefined;
|
|
|
|
return (
|
|
<div className="my-2 flex justify-start">
|
|
<div data-testid="media-card" data-status={block.status} className={CARD_CLASS}>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="flex min-w-0 items-center gap-2.5">
|
|
<Icon aria-hidden="true" className="size-icon-md shrink-0 text-muted-foreground" />
|
|
<span
|
|
id={titleId}
|
|
className="truncate text-ui-body font-medium text-foreground"
|
|
dir="auto"
|
|
title={block.title}
|
|
>
|
|
{block.title}
|
|
</span>
|
|
</span>
|
|
<span className="flex shrink-0 items-center gap-1.5">
|
|
<StatusLabel status={block.status} />
|
|
{active && act ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
disabled={busy}
|
|
onClick={() => act({ kind: "cancel" })}
|
|
>
|
|
{t`Cancel`}
|
|
</Button>
|
|
) : null}
|
|
</span>
|
|
</div>
|
|
{active ? (
|
|
<div
|
|
role="progressbar"
|
|
aria-labelledby={titleId}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={block.progress === undefined ? undefined : percent}
|
|
className="mt-2.5 h-1 overflow-hidden rounded-full bg-muted"
|
|
>
|
|
<div
|
|
className={cn(
|
|
"h-full rounded-full bg-primary transition-[width] duration-500",
|
|
block.progress === undefined && "w-1/3 animate-pulse",
|
|
)}
|
|
style={block.progress === undefined ? undefined : { width: `${percent}%` }}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
{showError ? (
|
|
<p className="mt-2 text-ui-body-sm text-destructive" dir="auto">
|
|
{block.error}
|
|
</p>
|
|
) : null}
|
|
{images.length > 0 ? (
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{images.map((output) => (
|
|
<ArtifactImage
|
|
key={output.artifactId}
|
|
artifactId={output.artifactId}
|
|
name={output.name}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{others.map((output) => (
|
|
<div key={output.artifactId} className="mt-3 flex flex-col gap-1.5">
|
|
{output.kind === "video" || output.kind === "audio" ? (
|
|
<>
|
|
<MediaPlayer
|
|
artifactId={output.artifactId}
|
|
kind={output.kind}
|
|
name={output.name}
|
|
posterArtifactId={output.posterArtifactId}
|
|
width={output.width}
|
|
height={output.height}
|
|
/>
|
|
<PlatformLine output={output} />
|
|
{active ? null : (
|
|
<OutputActions output={output} available={available} busy={busy} onAction={act} />
|
|
)}
|
|
</>
|
|
) : (
|
|
<ArtifactFileCard
|
|
artifactId={output.artifactId}
|
|
name={output.name}
|
|
mimeType={output.mimeType}
|
|
size={output.size}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
{skipped.map((target) => (
|
|
<p key={target.platform} className="mt-2 text-ui-body-sm text-destructive">
|
|
{platformIssueLine(target.platform, target.issues[0]!)}
|
|
</p>
|
|
))}
|
|
{actionError ? (
|
|
<p role="alert" className="mt-2 text-ui-body-sm text-destructive" dir="auto">
|
|
{actionError}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const CAPABILITIES_TTL_MS = 60_000;
|
|
let capabilities:
|
|
| { conversation: string; at: number; value: Promise<MediaCardAvailability> }
|
|
| undefined;
|
|
|
|
/**
|
|
* What the card's space can run, shared by every card of the conversation on screen and
|
|
* refreshed after a minute. Asked through a job, since the main conversation can live in another
|
|
* space than the one selected.
|
|
*/
|
|
function loadAvailability(target: ArtifactTarget, jobId: string): Promise<MediaCardAvailability> {
|
|
const conversation = "botId" in target ? `bot:${target.botId}` : `group:${target.groupId}`;
|
|
if (
|
|
!capabilities ||
|
|
capabilities.conversation !== conversation ||
|
|
Date.now() - capabilities.at > CAPABILITIES_TTL_MS
|
|
) {
|
|
capabilities = {
|
|
conversation,
|
|
at: Date.now(),
|
|
value: rpc.media
|
|
.capabilities({ jobId })
|
|
.then((value) => ({
|
|
export: value.export_video === true,
|
|
transcribe: value.transcribe === true,
|
|
}))
|
|
.catch(() => NOTHING_AVAILABLE),
|
|
};
|
|
}
|
|
return capabilities.value;
|
|
}
|
|
|
|
function useAvailability(
|
|
enabled: boolean,
|
|
target: ArtifactTarget,
|
|
jobId: string,
|
|
): MediaCardAvailability {
|
|
const [available, setAvailable] = useState(NOTHING_AVAILABLE);
|
|
useEffect(() => {
|
|
if (!enabled) return;
|
|
let current = true;
|
|
void loadAvailability(target, jobId).then((value) => {
|
|
if (current) setAvailable(value);
|
|
});
|
|
return () => {
|
|
current = false;
|
|
};
|
|
}, [enabled, target, jobId]);
|
|
return available;
|
|
}
|
|
|
|
/** The media card in a conversation, with its follow-up actions wired to the media job RPCs. */
|
|
export function MediaCard({
|
|
block,
|
|
target,
|
|
messageId,
|
|
}: {
|
|
block: MediaJobBlock;
|
|
target: ArtifactTarget;
|
|
/** The message carrying the card; a follow-up job is posted by the same bot. */
|
|
messageId: string;
|
|
}) {
|
|
// Follow-ups apply to video and audio of a settled job only; other cards never ask.
|
|
const available = useAvailability(
|
|
!isActiveMediaStatus(block.status) &&
|
|
block.outputs.some((output) => output.kind === "video" || output.kind === "audio"),
|
|
target,
|
|
block.jobId,
|
|
);
|
|
const onAction = async (action: MediaCardAction) => {
|
|
if (action.kind === "cancel") {
|
|
await rpc.media.jobs.cancel({ jobId: block.jobId });
|
|
return;
|
|
}
|
|
await rpc.media.jobs.start({
|
|
...target,
|
|
messageId,
|
|
...(action.kind === "export"
|
|
? {
|
|
operation: "export_video",
|
|
input:
|
|
"platform" in action
|
|
? { artifact_id: action.artifactId, platforms: [action.platform] }
|
|
: { artifact_id: action.artifactId, presets: [action.preset] },
|
|
}
|
|
: // The new card is titled with the file it transcribes.
|
|
{
|
|
operation: "transcribe",
|
|
input: { artifact_id: action.artifactId, title: action.name },
|
|
}),
|
|
});
|
|
};
|
|
return <MediaCardView block={block} available={available} onAction={onAction} />;
|
|
}
|