To cut a social video well the bot has to know where the cuts fit, and it could not look at a video at all. analyze_video is a read-only media job: one ffmpeg pass prints scene changes (scene score above 0.3, one per cut), pauses (silencedetect with the remove_silence defaults) and short-term loudness (ebur128) to files in the job directory, and a transcript made from the video by transcribe (an SRT whose parent is the video) adds when each line is spoken. The job stores nothing; its card just says done. The result reaches the bot through the existing media job path: the job always wakes the bot that started it (the tool has no continue_after) and the summary carries a short legend plus compact JSON. The runtime's summary limit grows to 12,000 characters and the summary is fitted into it: at most 200 entries per list, then every list shrinks evenly, keeping the strongest cuts, the longest pauses and the start of the transcript, with omitted counts. The wake prompt no longer claims the card shows the result when a job made no files. Jobs read derived artifacts through a new fetchDerivedArtifact, scoped to the job's user like fetchArtifact. A migration admits the operation in the media_jobs check and adds the tool to existing Studio specialists that still edit video. The web card gets an icon; no new copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
import { useLingui } from "@lingui/react/macro";
|
|
import type {
|
|
MediaJobBlock,
|
|
MediaJobStatus,
|
|
MediaOperation,
|
|
MediaOutput,
|
|
MediaPresetId,
|
|
} from "@rakazo/contracts";
|
|
import { MEDIA_JOB_ACTIVE_STATUSES, MEDIA_PRESETS, MediaPresetIdSchema } from "@rakazo/contracts";
|
|
import {
|
|
Button,
|
|
buttonVariants,
|
|
cn,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@rakazo/ui-web";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import {
|
|
Captions,
|
|
ChevronDown,
|
|
Download,
|
|
Film,
|
|
ImageIcon,
|
|
ScanSearch,
|
|
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";
|
|
|
|
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: "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,
|
|
analyze_video: ScanSearch,
|
|
};
|
|
|
|
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 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 && 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">
|
|
{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 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 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}
|
|
{block.status === "failed" && block.error ? (
|
|
<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}
|
|
/>
|
|
{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>
|
|
))}
|
|
{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: { 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} />;
|
|
}
|