Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions migrations/0033_media_assets_external_ref_unique.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Fix 3 (final review): external_ref's plain index let two concurrent inserts for the
-- same idempotency key (e.g. "crayo:project:<id>") both pass the findAssetByExternalRef
-- "not found" check and create two asset rows. A unique partial index makes the second
-- insert fail at the database instead. The old non-unique index is dropped since the
-- new unique index already serves equality lookups by external_ref.
drop index if exists media_assets_external_ref_idx;
create unique index if not exists media_assets_external_ref_unique on media_assets (external_ref) where external_ref is not null;
23 changes: 11 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.20.0",
"eslint-config-prettier": "^10.1.1",
"eslint-formatter-compact": "^9.0.1",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
Expand Down
106 changes: 100 additions & 6 deletions src/components/agent/results.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,90 @@
import { collectAgentVisualResults } from "@/lib/agent-results";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { collectAgentVisualResults, type AgentVisualResults } from "@/lib/agent-results";
import type { AgentRunDetail } from "@/lib/agent";
import { agentRunQueryKey, isAgentBusy } from "@/lib/agent";
import { signLibraryAssetsFn } from "@/lib/server/library-fns";
import { retryClipExportFn } from "@/lib/server/agent-fns";
import { GlassCard } from "@/components/ui/glass-card";
import { isAgentBusy } from "@/lib/agent";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { userFacingErrorMessage } from "@/lib/errors";

function LibraryClips({
runId,
clips,
}: {
runId: string;
clips: AgentVisualResults["libraryClips"];
}) {
const queryClient = useQueryClient();
const ids = clips.map((c) => c.assetId).filter((id): id is string => Boolean(id));
const signed = useQuery({
queryKey: ["library-signed", ids.join(",")],
queryFn: () => signLibraryAssetsFn({ data: { assetIds: ids } }),
enabled: ids.length > 0,
staleTime: 10 * 60 * 1000,
});
const retry = useMutation({
mutationFn: (projectId: string) => retryClipExportFn({ data: { runId, projectId } }),
onSuccess: async () => {
toast.success("Export retried");
await queryClient.invalidateQueries({ queryKey: agentRunQueryKey(runId) });
},
onError: (error) => toast.error(userFacingErrorMessage(error)),
});
return (
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{clips.map((clip) => {
const row = signed.data?.find((r) => r.assetId === clip.assetId);
return (
<figure key={clip.projectId} className="overflow-hidden rounded-control bg-black/40">
{row?.previewUrl ? (
<video
src={row.previewUrl}
poster={row.thumbnailUrl ?? undefined}
controls
className="max-h-72 w-full"
preload="metadata"
/>
) : (
<div className="grid h-40 place-items-center text-caption text-muted">
{clip.status === "failed" ? "Export failed" : "Preparing…"}
</div>
)}
<figcaption className="flex items-center justify-between gap-2 px-2 py-1 text-caption text-muted">
<span className="truncate">
{clip.title}
{row
? ` · ${row.backend === "supabase" ? "Supabase Storage" : row.backend === "s3" ? "S3" : "local disk"}`
: ""}
</span>
{row?.downloadUrl ? (
<Button size="sm" variant="secondary" asChild>
<a href={row.downloadUrl}>Download</a>
</Button>
) : clip.status === "failed" ? (
<Button
size="sm"
variant="secondary"
disabled={retry.isPending}
onClick={() => retry.mutate(clip.projectId)}
title={clip.error ?? undefined}
>
Retry
</Button>
) : null}
</figcaption>
</figure>
);
})}
</div>
);
}

export function AgentResults({ detail }: { detail: AgentRunDetail }) {
if (isAgentBusy(detail.run.status)) return null;
const results = collectAgentVisualResults(detail);
if (results.empty && !results.summary) {
if (results.empty) {
if (detail.run.status !== "succeeded" && detail.run.status !== "failed") return null;
return (
<GlassCard>
Expand All @@ -25,7 +103,12 @@ export function AgentResults({ detail }: { detail: AgentRunDetail }) {
return (
<GlassCard>
<p className="text-caption text-muted">Results</p>
{results.summary ? <p className="mt-2 whitespace-pre-wrap text-body">{results.summary}</p> : null}
{results.summary ? (
<p className="mt-2 whitespace-pre-wrap text-body">{results.summary}</p>
) : null}
{results.libraryClips.length > 0 ? (
<LibraryClips runId={detail.run.id} clips={results.libraryClips} />
) : null}
{videos.length > 0 ? (
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{videos.map((row) => (
Expand All @@ -39,7 +122,13 @@ export function AgentResults({ detail }: { detail: AgentRunDetail }) {
{images.length > 0 ? (
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
{images.map((row) => (
<a key={row.url} href={row.url} target="_blank" rel="noreferrer" className="block overflow-hidden rounded-control">
<a
key={row.url}
href={row.url}
target="_blank"
rel="noreferrer"
className="block overflow-hidden rounded-control"
>
<img src={row.url} alt={row.label} className="h-36 w-full object-cover" />
</a>
))}
Expand Down Expand Up @@ -73,7 +162,12 @@ export function AgentResults({ detail }: { detail: AgentRunDetail }) {
<ul className="mt-3 grid gap-1 text-caption">
{links.map((row) => (
<li key={row.url}>
<a href={row.url} target="_blank" rel="noreferrer" className="text-accent underline-offset-2 hover:underline">
<a
href={row.url}
target="_blank"
rel="noreferrer"
className="text-accent underline-offset-2 hover:underline"
>
{row.label}: {row.url}
</a>
</li>
Expand Down
81 changes: 65 additions & 16 deletions src/components/agent/tool-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,23 @@ export function AgentToolCardView({
) : null}
{!crayoReady && RUNNABLE.has(card.ui) ? (
<p className="mt-2 text-caption text-warning">
Crayo isn’t connected. Paste your Crayo API key in Settings → Integrations → Crayo.ai (no redeploy needed).
Crayo isn’t connected. Paste your Crayo API key in Settings → Integrations → Crayo.ai (no
redeploy needed).
</p>
) : null}
</GlassCard>
);
}

const RUNNABLE = new Set<AgentSlashUi>(["short", "autoclip", "voiceover", "image", "import", "export", "ingest"]);
const RUNNABLE = new Set<AgentSlashUi>([
"short",
"autoclip",
"voiceover",
"image",
"import",
"export",
"ingest",
]);

const CARD_COPY: Record<
AgentSlashUi,
Expand Down Expand Up @@ -166,7 +175,7 @@ const CARD_COPY: Record<
},
ingest: {
title: "Ingest to library",
hint: "Crayo CDN https only → Filebase, source=AGENT.",
hint: "Crayo CDN https only → Library, source=AGENT.",
action: "Ingest",
icon: <Upload className="size-4" aria-hidden="true" />,
},
Expand All @@ -192,7 +201,8 @@ const CARD_COPY: Record<

function canSubmit(ui: AgentSlashUi, draft: Record<string, string>): boolean {
if (ui === "short") return Boolean(draft.topic?.trim() || draft.script?.trim());
if (ui === "autoclip") return Boolean(draft.url?.trim()) && autoclipSourceProblem(draft.url ?? "") === null;
if (ui === "autoclip")
return Boolean(draft.url?.trim()) && autoclipSourceProblem(draft.url ?? "") === null;
if (ui === "import" || ui === "ingest") return Boolean(draft.url?.startsWith("https://"));
if (ui === "voiceover") return Boolean(draft.script?.trim() && draft.voiceId?.trim());
if (ui === "image") return Boolean(draft.prompt?.trim());
Expand All @@ -208,13 +218,20 @@ function buildGoal(
if (ui === "short") {
return {
preset: "crayo-short",
goal: buildCrayoShortGoal({ topic: draft.topic ?? "", script: draft.script ?? "", clientName }),
goal: buildCrayoShortGoal({
topic: draft.topic ?? "",
script: draft.script ?? "",
clientName,
}),
};
}
if (ui === "autoclip") {
return {
preset: "crayo-autoclip",
goal: buildCrayoAutoclipGoal({ url: draft.url ?? "", clipCount: Number(draft.clipCount ?? 5) }),
goal: buildCrayoAutoclipGoal({
url: draft.url ?? "",
clipCount: Number(draft.clipCount ?? 5),
}),
};
}
if (ui === "voiceover") {
Expand All @@ -230,17 +247,29 @@ function buildGoal(
if (ui === "image") {
return {
preset: "crayo-image",
goal: buildCrayoImageGoal({ prompt: draft.prompt ?? "", aspectRatio: draft.aspectRatio || "9:16" }),
goal: buildCrayoImageGoal({
prompt: draft.prompt ?? "",
aspectRatio: draft.aspectRatio || "9:16",
}),
};
}
if (ui === "import") {
return { preset: "crayo-import", goal: buildCrayoImportGoal({ url: draft.url ?? "", name: draft.name }) };
return {
preset: "crayo-import",
goal: buildCrayoImportGoal({ url: draft.url ?? "", name: draft.name }),
};
}
if (ui === "export") {
return { preset: "crayo-export", goal: buildCrayoExportGoal({ projectId: draft.projectId ?? "" }) };
return {
preset: "crayo-export",
goal: buildCrayoExportGoal({ projectId: draft.projectId ?? "" }),
};
}
if (ui === "ingest") {
return { preset: "crayo-ingest", goal: buildCrayoIngestGoal({ url: draft.url ?? "", title: draft.title }) };
return {
preset: "crayo-ingest",
goal: buildCrayoIngestGoal({ url: draft.url ?? "", title: draft.title }),
};
}
return null;
}
Expand Down Expand Up @@ -300,7 +329,11 @@ function AutoclipFields({
aria-invalid={problem ? true : undefined}
aria-describedby="card-long-url-hint"
/>
<p id="card-long-url-hint" className={problem ? "text-caption text-warning" : "text-caption text-muted-foreground"} role={problem ? "alert" : undefined}>
<p
id="card-long-url-hint"
className={problem ? "text-caption text-warning" : "text-caption text-muted-foreground"}
role={problem ? "alert" : undefined}
>
{problem ??
"YouTube, TikTok, Vimeo, X or Twitch links are fetched in a sandbox at 720p and uploaded to Crayo. Streams over 3 h are split into ~70-min segments, each its own AutoClip job (clips and credits spread across them). Direct file links (mp4/mov ≤100MB) import straight away."}
</p>
Expand Down Expand Up @@ -363,7 +396,10 @@ function VoiceoverFields({
: "No voices returned. Check Crayo credits, then retry."}
</p>
) : (
<Select value={draft.voiceId || undefined} onValueChange={(value) => set({ voiceId: value })}>
<Select
value={draft.voiceId || undefined}
onValueChange={(value) => set({ voiceId: value })}
>
<SelectTrigger id="card-voice">
<SelectValue placeholder="Pick a voice" />
</SelectTrigger>
Expand Down Expand Up @@ -412,7 +448,10 @@ function ImageFields({
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="card-aspect">Aspect</Label>
<Select value={draft.aspectRatio || "9:16"} onValueChange={(value) => set({ aspectRatio: value })}>
<Select
value={draft.aspectRatio || "9:16"}
onValueChange={(value) => set({ aspectRatio: value })}
>
<SelectTrigger id="card-aspect">
<SelectValue />
</SelectTrigger>
Expand Down Expand Up @@ -534,7 +573,10 @@ function VoicesBrowser({ crayoReady }: { crayoReady: boolean }) {
return (
<ul className="grid max-h-64 gap-1 overflow-y-auto">
{voicesQuery.data.voices.map((voice) => (
<li key={voice.id} className="flex items-baseline justify-between gap-2 rounded-control bg-secondary-surface/50 px-3 py-2">
<li
key={voice.id}
className="flex items-baseline justify-between gap-2 rounded-control bg-secondary-surface/50 px-3 py-2"
>
<span className="text-body">{voice.name}</span>
<span className="font-mono text-caption text-muted">{voice.id}</span>
</li>
Expand Down Expand Up @@ -581,15 +623,22 @@ function AssetsBrowser({ crayoReady }: { crayoReady: boolean }) {
}
if (query.isPending) return <Skeleton className="h-24" />;
if (!query.data?.ok) {
return <p className="text-caption text-warning">{explainAgentToolError(query.data?.error ?? "MISSING")}</p>;
return (
<p className="text-caption text-warning">
{explainAgentToolError(query.data?.error ?? "MISSING")}
</p>
);
}
if (query.data.assets.length === 0) {
return <p className="text-caption text-muted">No assets in this Crayo account yet.</p>;
}
return (
<ul className="grid max-h-64 gap-1 overflow-y-auto">
{query.data.assets.map((asset) => (
<li key={asset.id} className="flex items-baseline justify-between gap-2 rounded-control bg-secondary-surface/50 px-3 py-2">
<li
key={asset.id}
className="flex items-baseline justify-between gap-2 rounded-control bg-secondary-surface/50 px-3 py-2"
>
<span className="min-w-0 truncate text-body">{asset.name}</span>
<span className="shrink-0 text-caption text-muted">{asset.type}</span>
</li>
Expand Down
Loading
Loading