diff --git a/migrations/0033_media_assets_external_ref_unique.sql b/migrations/0033_media_assets_external_ref_unique.sql new file mode 100644 index 0000000..46d4e0c --- /dev/null +++ b/migrations/0033_media_assets_external_ref_unique.sql @@ -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:") 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; diff --git a/package-lock.json b/package-lock.json index 7d8ca99..b2f073c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,6 +74,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", @@ -6714,6 +6715,16 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-formatter-compact": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/eslint-formatter-compact/-/eslint-formatter-compact-9.0.1.tgz", + "integrity": "sha512-mBAti2tb403dQGMyilQTYHU80stem3N7jdtKW+tmn5gj3JNF7ki0rgCZtJFw4iMayTH862FTUIqCdp70ug0S0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.5.6", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", @@ -8354,18 +8365,6 @@ } } }, - "node_modules/nitro/node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/nitro/node_modules/rou3": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.8.1.tgz", diff --git a/package.json b/package.json index 408316f..c1991fe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/agent/results.tsx b/src/components/agent/results.tsx index 43c870c..f1e65b1 100644 --- a/src/components/agent/results.tsx +++ b/src/components/agent/results.tsx @@ -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 ( +
+ {clips.map((clip) => { + const row = signed.data?.find((r) => r.assetId === clip.assetId); + return ( +
+ {row?.previewUrl ? ( +
+ ); + })} +
+ ); +} 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 ( @@ -25,7 +103,12 @@ export function AgentResults({ detail }: { detail: AgentRunDetail }) { return (

Results

- {results.summary ?

{results.summary}

: null} + {results.summary ? ( +

{results.summary}

+ ) : null} + {results.libraryClips.length > 0 ? ( + + ) : null} {videos.length > 0 ? (
{videos.map((row) => ( @@ -39,7 +122,13 @@ export function AgentResults({ detail }: { detail: AgentRunDetail }) { {images.length > 0 ? (
{images.map((row) => ( - + {row.label} ))} @@ -73,7 +162,12 @@ export function AgentResults({ detail }: { detail: AgentRunDetail }) {
    {links.map((row) => (
  • - + {row.label}: {row.url}
  • diff --git a/src/components/agent/tool-cards.tsx b/src/components/agent/tool-cards.tsx index d0bf376..a11767d 100644 --- a/src/components/agent/tool-cards.tsx +++ b/src/components/agent/tool-cards.tsx @@ -115,14 +115,23 @@ export function AgentToolCardView({ ) : null} {!crayoReady && RUNNABLE.has(card.ui) ? (

    - 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).

    ) : null} ); } -const RUNNABLE = new Set(["short", "autoclip", "voiceover", "image", "import", "export", "ingest"]); +const RUNNABLE = new Set([ + "short", + "autoclip", + "voiceover", + "image", + "import", + "export", + "ingest", +]); const CARD_COPY: Record< AgentSlashUi, @@ -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:
- set({ aspectRatio: value })} + > @@ -534,7 +573,10 @@ function VoicesBrowser({ crayoReady }: { crayoReady: boolean }) { return (
    {voicesQuery.data.voices.map((voice) => ( -
  • +
  • {voice.name} {voice.id}
  • @@ -581,7 +623,11 @@ function AssetsBrowser({ crayoReady }: { crayoReady: boolean }) { } if (query.isPending) return ; if (!query.data?.ok) { - return

    {explainAgentToolError(query.data?.error ?? "MISSING")}

    ; + return ( +

    + {explainAgentToolError(query.data?.error ?? "MISSING")} +

    + ); } if (query.data.assets.length === 0) { return

    No assets in this Crayo account yet.

    ; @@ -589,7 +635,10 @@ function AssetsBrowser({ crayoReady }: { crayoReady: boolean }) { return (
      {query.data.assets.map((asset) => ( -
    • +
    • {asset.name} {asset.type}
    • diff --git a/src/components/library/asset-card.tsx b/src/components/library/asset-card.tsx index 4de15a9..1de026d 100644 --- a/src/components/library/asset-card.tsx +++ b/src/components/library/asset-card.tsx @@ -1,14 +1,9 @@ -import { Film, Image as ImageIcon } from "lucide-react"; +import { Download, Film, Image as ImageIcon } from "lucide-react"; import { Badge, statusTone } from "@/components/ui/badge"; import { GlassCard } from "@/components/ui/glass-card"; import { ScoreBadge } from "@/components/performance/score-badge"; import { cn } from "@/lib/utils"; -import { - SOURCE_LABELS, - formatBytes, - formatDurationSec, - type LibraryAsset, -} from "@/lib/library"; +import { SOURCE_LABELS, formatBytes, formatDurationSec, type LibraryAsset } from "@/lib/library"; import type { AssetPerformanceRollup } from "@/lib/performance"; import { formatUnknownNumber } from "@/lib/performance"; import { formatCompactCount } from "@/lib/format"; @@ -89,24 +84,35 @@ export function AssetCard({ {SOURCE_LABELS[asset.source]} {asset.aspectRatio ? {asset.aspectRatio} : null} {rollup ? ( - 0 ? "WINNER" : null} - /> + 0 ? "WINNER" : null} /> + ) : null} +
+
+

+ {[ + asset.kind.toLowerCase(), + formatBytes(asset.byteSize), + rollup?.viewsTotal != null + ? `${formatUnknownNumber(rollup.viewsTotal, formatCompactCount)} views` + : null, + ] + .filter(Boolean) + .join(" · ")} +

+ {asset.previewUrl && + (asset.kind === "VIDEO" || asset.kind === "IMAGE" || asset.kind === "AUDIO") ? ( + e.stopPropagation()} + > + ) : null}
-

- {[ - asset.kind.toLowerCase(), - formatBytes(asset.byteSize), - rollup?.viewsTotal != null - ? `${formatUnknownNumber(rollup.viewsTotal, formatCompactCount)} views` - : null, - ] - .filter(Boolean) - .join(" · ")} -

); -} \ No newline at end of file +} diff --git a/src/components/library/asset-drawer.tsx b/src/components/library/asset-drawer.tsx index 92220f9..282ff72 100644 --- a/src/components/library/asset-drawer.tsx +++ b/src/components/library/asset-drawer.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { Download } from "lucide-react"; import { Badge, statusTone } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -23,7 +24,12 @@ import { type MediaPipelineSettings, type RenderPreset, } from "@/lib/library"; -import { PLATFORM_LABELS, WINDOW_LABELS, formatEngagementPct, formatUnknownNumber } from "@/lib/performance"; +import { + PLATFORM_LABELS, + WINDOW_LABELS, + formatEngagementPct, + formatUnknownNumber, +} from "@/lib/performance"; import { formatCompactCount } from "@/lib/format"; export function AssetDrawer({ @@ -66,7 +72,8 @@ export function AssetDrawer({ const media = useMemo(() => { if (!asset?.previewUrl) return null; - if (asset.kind === "IMAGE") return ; + if (asset.kind === "IMAGE") + return ; if (asset.kind === "VIDEO") { return (