From bf2bdbf2d96a0b3de803a03c72bd2be40bb17d80 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:45:25 +0000 Subject: [PATCH 01/14] feat(library-pipeline): ingestFile (disk-streamed) and attachThumbnail Co-Authored-By: Claude Sonnet 5 --- src/lib/server/library-pipeline.server.ts | 114 ++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/src/lib/server/library-pipeline.server.ts b/src/lib/server/library-pipeline.server.ts index 248be54..433f8b8 100644 --- a/src/lib/server/library-pipeline.server.ts +++ b/src/lib/server/library-pipeline.server.ts @@ -248,6 +248,120 @@ export async function ingestBytes(input: { return { asset, duplicate: false }; } +async function hashFile(path: string): Promise { + const { createHash } = await import("node:crypto"); + const { createReadStream } = await import("node:fs"); + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + createReadStream(path).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest("hex"))); + }); +} + +/** Disk-backed twin of ingestBytes: streams into storage and probes the file where it sits. */ +export async function ingestFile(input: { + actorId: string; + clientId: string | null; + title: string; + filePath: string; + mimeHint: string; + filename: string; + source: AssetSource; + sourceRef?: string | null; + externalRef?: string | null; + tags?: string[]; + note?: string; +}): Promise<{ asset: LibraryAsset; duplicate: boolean }> { + const { stat } = await import("node:fs/promises"); + const { writeLibraryFile } = await import("@/lib/server/library-storage.server"); + const byteSize = (await stat(input.filePath)).size; + const mime = input.mimeHint || "application/octet-stream"; + const kind = kindFromMime(mime); + const checksum = await hashFile(input.filePath); + const existing = await findByChecksum(input.clientId, checksum); + if (existing) return { asset: existing, duplicate: true }; + + const assetId = libraryNewId(); + const versionId = libraryNewId(); + const ext = extFromMime(mime, input.filename); + const key = makeStorageKey(assetId, versionId, ext); + const storageKey = await writeLibraryFile(key, input.filePath, mime); + const title = sanitizeText(input.title || input.filename || "Untitled").slice(0, 160) || "Untitled"; + await insertAsset({ + id: assetId, + client_id: input.clientId, + kind, + title, + source: input.source, + source_ref: input.sourceRef ?? null, + external_ref: input.externalRef ?? null, + status: "PROCESSING", + mime_type: mime, + byte_size: byteSize, + checksum, + current_version_id: versionId, + tags: input.tags, + created_by: input.actorId, + }); + await insertVersion({ + id: versionId, + asset_id: assetId, + version_number: 1, + storage_key: storageKey, + mime_type: mime, + byte_size: byteSize, + checksum, + note: input.note ?? "original", + }); + await finalizeProbeFromPath(assetId, versionId, input.filePath, mime, byteSize, checksum); + await audit(input.actorId, "library.ingest", assetId); + emitAutonomyEvent({ type: "library.asset.ready", entityType: "media_asset", entityId: assetId, data: { source: input.source, kind, clientId: input.clientId } }); + const asset = await getAsset(assetId); + if (!asset) throw new Error("ASSET_MISSING"); + return { asset, duplicate: false }; +} + +async function finalizeProbeFromPath(assetId: string, versionId: string, path: string, mime: string, byteSize: number, checksum: string) { + try { + await assertReadableMedia(path); + } catch { + await patchAsset(assetId, { status: "FAILED" }); + return; + } + const probe = await probeFile(path); + await patchAsset(assetId, { + status: "READY", + mime_type: mime, + byte_size: byteSize, + checksum, + current_version_id: versionId, + duration_sec: probe.durationSec, + width: probe.width, + height: probe.height, + aspect_ratio: aspectLabel(probe.width, probe.height), + }); +} + +/** Store a small thumbnail as an extra version row and point the asset at it. */ +export async function attachThumbnail(input: { assetId: string; bytes: Buffer; mimeHint: string }): Promise { + const { writeLibraryBytes } = await import("@/lib/server/library-storage.server"); + const mime = sniffMime(input.bytes, input.mimeHint, "thumb.jpg"); + const versionId = libraryNewId(); + const key = makeStorageKey(input.assetId, versionId, extFromMime(mime, "thumb.jpg")); + const storageKey = await writeLibraryBytes(key, input.bytes); + await insertVersion({ + id: versionId, + asset_id: input.assetId, + version_number: 0, + storage_key: storageKey, + mime_type: mime, + byte_size: input.bytes.length, + checksum: await hashBytes(input.bytes), + note: "thumbnail", + }); + await patchAsset(input.assetId, { thumbnail_version_id: versionId }); + return versionId; +} + async function finalizeProbe( assetId: string, versionId: string, From 0db6c71de63002dfdb75aa501e67030e72c73f45 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:53:59 +0000 Subject: [PATCH 02/14] feat(clip-export): resumable Crayo export to library asset --- src/lib/server/clip-export.server.test.ts | 82 +++++++++++ src/lib/server/clip-export.server.ts | 169 ++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 src/lib/server/clip-export.server.test.ts create mode 100644 src/lib/server/clip-export.server.ts diff --git a/src/lib/server/clip-export.server.test.ts b/src/lib/server/clip-export.server.test.ts new file mode 100644 index 0000000..f0ebac5 --- /dev/null +++ b/src/lib/server/clip-export.server.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { exportClipStep, readExportCredits, type ClipExportDeps, type ClipExportState } from "./clip-export.server.ts"; + +function fakeDeps(over: Partial & { exportStatuses?: string[] } = {}): ClipExportDeps & { calls: string[] } { + const calls: string[] = []; + const statuses = [...(over.exportStatuses ?? ["processing", "completed"])]; + return { + calls, + crayo: { + async exportProject(id) { calls.push(`export:${id}`); return { export: { id: "exp_1", status: "queued" } }; }, + async getExport(id) { calls.push(`poll:${id}`); const s = statuses.shift() ?? "completed"; return { export: { id, status: s, video_url: "https://cdn-crayo.com/c.mp4", file_size: 1000 } }; }, + async getAccount() { return { credits: { export: 7 } }; }, + }, + findByExternalRef: over.findByExternalRef ?? (async () => null), + download: over.download ?? (async (url) => { calls.push(`download:${url}`); return { path: "/tmp/fake.mp4", bytes: 1000, mime: "video/mp4", cleanup: async () => { calls.push("cleanup"); } }; }), + ingest: over.ingest ?? (async () => { calls.push("ingest"); return { assetId: "asset_1" }; }), + attachThumbnail: over.attachThumbnail ?? (async () => { calls.push("thumb"); }), + }; +} +const base = (): ClipExportState => ({ projectId: "p1", title: "Clip", thumbnailUrl: "https://cdn-crayo.com/t.jpg", exportId: null, status: "pending", assetId: null, error: null, bytes: null }); +const ctx = { actorId: "u1", clientId: null, sourceUrl: "https://www.youtube.com/watch?v=x", tags: ["autoclip"] }; + +test("pending → exporting starts the export once", async () => { + const deps = fakeDeps(); + const s1 = await exportClipStep(base(), ctx, deps); + assert.equal(s1.status, "exporting"); + assert.equal(s1.exportId, "exp_1"); + assert.deepEqual(deps.calls, ["export:p1"]); +}); + +test("exporting polls until done, then downloads, ingests, attaches thumbnail, cleans up", async () => { + const deps = fakeDeps(); + let s = await exportClipStep(base(), ctx, deps); + s = await exportClipStep(s, ctx, deps); // processing + assert.equal(s.status, "exporting"); + s = await exportClipStep(s, ctx, deps); // completed + assert.equal(s.status, "stored"); + assert.equal(s.assetId, "asset_1"); + assert.equal(s.bytes, 1000); + assert.deepEqual(deps.calls, ["export:p1", "poll:exp_1", "poll:exp_1", "download:https://cdn-crayo.com/c.mp4", "ingest", "thumb", "cleanup"]); +}); + +test("already-stored project is a no-op via external ref", async () => { + const deps = fakeDeps({ findByExternalRef: async () => ({ id: "asset_9" }) }); + const s = await exportClipStep(base(), ctx, deps); + assert.equal(s.status, "stored"); + assert.equal(s.assetId, "asset_9"); + assert.deepEqual(deps.calls, []); +}); + +test("failed export marks only this clip failed with the provider message", async () => { + const deps = fakeDeps({ exportStatuses: ["failed"] }); + let s = await exportClipStep(base(), ctx, deps); + s = await exportClipStep(s, ctx, deps); + assert.equal(s.status, "failed"); + assert.match(s.error ?? "", /export failed/i); +}); + +test("storage failure cleans up and fails the clip", async () => { + const deps = fakeDeps({ exportStatuses: ["completed"], ingest: async () => { throw new Error("SUPABASE_DOWN"); } }); + let s = await exportClipStep(base(), ctx, deps); + s = await exportClipStep(s, ctx, deps); + assert.equal(s.status, "failed"); + assert.equal(s.error, "SUPABASE_DOWN"); + assert.ok(deps.calls.includes("cleanup")); +}); + +test("oversized export is refused before download", async () => { + const deps = fakeDeps(); + deps.crayo.getExport = async (id) => ({ export: { id, status: "completed", video_url: "https://cdn-crayo.com/big.mp4", file_size: 600 * 1024 * 1024 } }); + let s = await exportClipStep(base(), ctx, deps); + s = await exportClipStep(s, ctx, deps); + assert.equal(s.status, "failed"); + assert.match(s.error ?? "", /512 MB/); + assert.ok(!deps.calls.some((c) => c.startsWith("download:"))); +}); + +test("readExportCredits tolerates a failing account call", async () => { + assert.equal(await readExportCredits({ async exportProject() { return null; }, async getExport() { return null; }, async getAccount() { return { credits: { export: 7 } }; } }), 7); + assert.equal(await readExportCredits({ async exportProject() { return null; }, async getExport() { return null; }, async getAccount() { throw new Error("x"); } }), null); +}); diff --git a/src/lib/server/clip-export.server.ts b/src/lib/server/clip-export.server.ts new file mode 100644 index 0000000..e5e628a --- /dev/null +++ b/src/lib/server/clip-export.server.ts @@ -0,0 +1,169 @@ +/** + * Crayo clip project → stored Library asset. Pure orchestration over injected deps so the + * transitions are unit-testable; `defaultClipExportDeps()` wires the real Crayo client, + * library pipeline and storage. Never import from client code. + */ +import { BUCKET_OBJECT_LIMIT_BYTES, clipExternalRef, readExportPayload } from "@/lib/clip-export"; + +export type CrayoExportClient = { + exportProject(projectId: string): Promise; + getExport(exportId: string): Promise; + getAccount(): Promise; +}; + +export type ClipExportState = { + projectId: string; + title: string; + thumbnailUrl: string | null; + exportId: string | null; + status: "pending" | "exporting" | "stored" | "failed"; + assetId: string | null; + error: string | null; + bytes: number | null; +}; + +export type ClipExportDeps = { + crayo: CrayoExportClient; + findByExternalRef(ref: string): Promise<{ id: string } | null>; + download(url: string, maxBytes: number): Promise<{ path: string; bytes: number; mime: string; cleanup(): Promise }>; + ingest(input: { + path: string; bytes: number; mime: string; title: string; externalRef: string; sourceRef: string; + actorId: string; clientId: string | null; tags: string[]; + }): Promise<{ assetId: string }>; + attachThumbnail(assetId: string, thumbnailUrl: string): Promise; +}; + +export type ClipExportContext = { actorId: string; clientId: string | null; sourceUrl: string; tags: string[] }; + +const POLL_MS = 3000; +const MAX_POLLS = 60; + +function message(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).replace(/https?:\/\/\S+/g, "").slice(0, 300); +} + +export async function readExportCredits(crayo: CrayoExportClient): Promise { + try { + const raw = (await crayo.getAccount()) as { credits?: { export?: unknown } } | null; + const value = raw?.credits?.export; + return typeof value === "number" && Number.isFinite(value) ? value : null; + } catch { + return null; + } +} + +/** One transition. Safe to call repeatedly; terminal states return unchanged. */ +export async function exportClipStep(state: ClipExportState, ctx: ClipExportContext, deps: ClipExportDeps): Promise { + if (state.status === "stored" || state.status === "failed") return state; + const ref = clipExternalRef(state.projectId); + try { + if (state.status === "pending") { + const existing = await deps.findByExternalRef(ref); + if (existing) return { ...state, status: "stored", assetId: existing.id, error: null }; + const queued = readExportPayload(await deps.crayo.exportProject(state.projectId)); + if (!queued.exportId) return { ...state, status: "failed", error: "Crayo did not return an export id." }; + return { ...state, status: "exporting", exportId: queued.exportId }; + } + // exporting + const polled = readExportPayload(await deps.crayo.getExport(state.exportId!)); + if (polled.status === "pending") return state; + if (polled.status === "failed" || !polled.url) return { ...state, status: "failed", error: "Crayo export failed." }; + if (polled.bytes != null && polled.bytes > BUCKET_OBJECT_LIMIT_BYTES) { + return { ...state, status: "failed", error: `Export is ${Math.round(polled.bytes / 1048576)} MB; the library bucket allows 512 MB per file.` }; + } + const file = await deps.download(polled.url, BUCKET_OBJECT_LIMIT_BYTES); + try { + const { assetId } = await deps.ingest({ + path: file.path, bytes: file.bytes, mime: file.mime, title: state.title, externalRef: ref, + sourceRef: ctx.sourceUrl, actorId: ctx.actorId, clientId: ctx.clientId, tags: ctx.tags, + }); + if (state.thumbnailUrl) await deps.attachThumbnail(assetId, state.thumbnailUrl).catch(() => {}); + return { ...state, status: "stored", assetId, bytes: file.bytes, error: null }; + } finally { + await file.cleanup(); + } + } catch (error) { + return { ...state, status: "failed", error: message(error) }; + } +} + +/** Synchronous convenience for callers that can wait (direct-file /autoclip, /export). */ +export async function exportClipToLibrary( + input: { projectId: string; title: string; thumbnailUrl: string | null; actorId: string; clientId: string | null; sourceUrl: string; tags?: string[] }, + deps: ClipExportDeps = defaultClipExportDeps(), +): Promise { + let state: ClipExportState = { projectId: input.projectId, title: input.title, thumbnailUrl: input.thumbnailUrl, exportId: null, status: "pending", assetId: null, error: null, bytes: null }; + const ctx: ClipExportContext = { actorId: input.actorId, clientId: input.clientId, sourceUrl: input.sourceUrl, tags: input.tags ?? ["crayo", "autoclip"] }; + for (let i = 0; i < MAX_POLLS + 2; i += 1) { + const next = await exportClipStep(state, ctx, deps); + if (next.status === "stored" || next.status === "failed") return next; + if (next.status === "exporting" && state.status === "exporting") await new Promise((r) => setTimeout(r, POLL_MS)); + state = next; + } + return { ...state, status: "failed", error: "Crayo export is still processing." }; +} + +/** Stream an https URL to a temp file with a byte ceiling. */ +export async function downloadToTempFile(url: string, maxBytes: number): Promise<{ path: string; bytes: number; mime: string; cleanup(): Promise }> { + const { mkdtemp, rm } = await import("node:fs/promises"); + const { createWriteStream } = await import("node:fs"); + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { pipeline } = await import("node:stream/promises"); + const { Readable, Transform } = await import("node:stream"); + const { isTrustedLibraryUrl } = await import("@/lib/server/library-pipeline.server"); + if (!isTrustedLibraryUrl(url)) throw new Error("UNTRUSTED_URL"); + const response = await fetch(url, { redirect: "follow", headers: { "User-Agent": "ClippyAdmin/1.0" }, signal: AbortSignal.timeout(10 * 60 * 1000) }); + if (!response.ok || !response.body) throw new Error("UNTRUSTED_URL"); + const declared = Number(response.headers.get("content-length") ?? 0); + if (declared > maxBytes) throw new Error("MEDIA_TOO_LARGE"); + const dir = await mkdtemp(join(tmpdir(), "clip-export-")); + const path = join(dir, "clip.mp4"); + let bytes = 0; + const limiter = new Transform({ + transform(chunk, _enc, cb) { + bytes += chunk.length; + if (bytes > maxBytes) cb(new Error("MEDIA_TOO_LARGE")); + else cb(null, chunk); + }, + }); + const cleanup = async () => { await rm(dir, { recursive: true, force: true }); }; + try { + await pipeline(Readable.fromWeb(response.body as never), limiter, createWriteStream(path)); + } catch (error) { + await cleanup(); + throw error; + } + const mime = response.headers.get("content-type")?.split(";")[0]?.trim() || "video/mp4"; + return { path, bytes, mime, cleanup }; +} + +export function defaultClipExportDeps(): ClipExportDeps { + return { + crayo: { + exportProject: async (id) => (await import("@/lib/server/crayo.server")).crayoExportProject(id), + getExport: async (id) => (await import("@/lib/server/crayo.server")).crayoGetExport(id), + getAccount: async () => (await import("@/lib/server/crayo.server")).crayoGetAccount(), + }, + findByExternalRef: async (ref) => (await import("@/lib/server/library.server")).findAssetByExternalRef(ref), + download: downloadToTempFile, + ingest: async (input) => { + const { ingestFile } = await import("@/lib/server/library-pipeline.server"); + const { asset } = await ingestFile({ + actorId: input.actorId, clientId: input.clientId, title: input.title, filePath: input.path, + mimeHint: input.mime, filename: `${input.title}.mp4`, source: "AGENT", sourceRef: input.sourceRef.slice(0, 500), + externalRef: input.externalRef, tags: ["crayo", ...input.tags], note: "crayo export", + }); + return { assetId: asset.id }; + }, + attachThumbnail: async (assetId, thumbnailUrl) => { + const { isTrustedLibraryUrl, attachThumbnail } = await import("@/lib/server/library-pipeline.server"); + if (!isTrustedLibraryUrl(thumbnailUrl)) return; + const response = await fetch(thumbnailUrl, { signal: AbortSignal.timeout(20_000) }); + if (!response.ok) return; + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > 5 * 1024 * 1024) return; + await attachThumbnail({ assetId, bytes, mimeHint: response.headers.get("content-type") ?? "image/jpeg" }); + }, + }; +} From cb2cf1e54e69be51ada60ed6edb872a3862c094c Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:54:06 +0000 Subject: [PATCH 03/14] chore: add eslint-formatter-compact for the compact reporter --- package-lock.json | 23 +++++++++++------------ package.json | 1 + 2 files changed, 12 insertions(+), 12 deletions(-) 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", From 64a588f0f9a7024fc136fd9c2e70a3344901b5aa Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:04:48 +0000 Subject: [PATCH 04/14] feat(media-fetch-job): export AutoClip projects to the Library (exporting phase) --- src/lib/agent.ts | 2 + src/lib/server/media-fetch-job.server.ts | 137 +++++++++++++++++------ 2 files changed, 107 insertions(+), 32 deletions(-) diff --git a/src/lib/agent.ts b/src/lib/agent.ts index 11008bd..ceeeb05 100644 --- a/src/lib/agent.ts +++ b/src/lib/agent.ts @@ -674,6 +674,8 @@ export function explainAgentToolError(code: string): string { return "The video downloaded but the upload to Crayo’s signed URL failed. Retry; if it keeps failing, check Crayo storage headroom on crayo.ai."; case "UPLOAD_NOT_FOUND": return "Crayo never received the bytes on its signed upload URL. Retry the run."; + case "EXPORT_BUDGET": + return "Not enough Crayo export credits for these clips."; case "VALIDATION_ERROR": return "Crayo rejected the request: the URL could not be fetched, is not a public https media file, or the video is too short/long (AutoClip needs 1 min–3 h). No credits were spent on a rejected request."; case "UNSUPPORTED_MEDIA_TYPE": diff --git a/src/lib/server/media-fetch-job.server.ts b/src/lib/server/media-fetch-job.server.ts index bf6fd6d..07ccc9d 100644 --- a/src/lib/server/media-fetch-job.server.ts +++ b/src/lib/server/media-fetch-job.server.ts @@ -63,18 +63,20 @@ type SegmentState = { uploadId?: string; assetId?: string; autoclipId?: string; - clips?: { - title: string; - projectId: string | null; - thumbnailUrl: string | null; - library: unknown; - }[]; + clips?: import("@/lib/server/clip-export.server").ClipExportState[]; error?: string; }; export type MediaFetchJobState = { version: 1; - phase: "booting" | "downloading" | "uploading" | "autoclipping" | "done" | "failed"; + phase: + | "booting" + | "downloading" + | "uploading" + | "autoclipping" + | "exporting" + | "done" + | "failed"; url: string; sandboxId: string | null; actorId: string; @@ -94,6 +96,7 @@ export type MediaFetchJobState = { error: string | null; /** Consecutive ticks that failed on a transient sandbox/API error; reset once a tick reads the sandbox. */ transientErrors?: number; + exportBudget?: { count: number; credits: number | null } | null; }; const LOCK_MS = 25_000; @@ -608,25 +611,23 @@ export async function tickMediaFetchJob( } | null; const status = String(payload?.autoclip?.status ?? "").toLowerCase(); if (status === "completed" || status === "complete" || status === "succeeded") { - const { ingestCrayoMedia } = await import("@/lib/server/crayo-tools.server"); - const clips = []; - for (const clip of (payload?.autoclip?.clips ?? []).slice(0, 20) as Record< - string, - unknown - >[]) { - const title = String(clip.title ?? "AutoClip"); - const thumbnailUrl = typeof clip.thumbnail_url === "string" ? clip.thumbnail_url : null; - const projectId = typeof clip.project_id === "string" ? clip.project_id : null; - const library = thumbnailUrl - ? await ingestCrayoMedia(state.actorId, state.clientId, thumbnailUrl, title, [ - "autoclip", - ]) - : null; - clips.push({ title, projectId, thumbnailUrl, library }); - } - seg.clips = clips; + const { readAutoclipClips } = await import("@/lib/clip-export"); + seg.clips = readAutoclipClips(payload).map((clip) => ({ + projectId: clip.projectId, + title: clip.title, + thumbnailUrl: clip.thumbnailUrl, + exportId: null, + status: "pending" as const, + assetId: null, + error: null, + bytes: null, + })); seg.state = "done"; - await progress(runId, state, `Segment ${seg.index + 1}: ${clips.length} clip(s) ready.`); + await progress( + runId, + state, + `Segment ${seg.index + 1}: ${seg.clips.length} clip(s) ready in Crayo. Exporting next.`, + ); } else if (status === "failed" || status === "error") { return await fail( "FAILED", @@ -635,10 +636,81 @@ export async function tickMediaFetchJob( } } if (state.segments.every((seg) => seg.state === "done")) { + const { planExportBudget } = await import("@/lib/clip-export"); + const { readExportCredits, defaultClipExportDeps } = await import( + "@/lib/server/clip-export.server" + ); + const allClips = state.segments.flatMap((seg) => seg.clips ?? []); + const credits = await readExportCredits(defaultClipExportDeps().crayo); + const budget = planExportBudget({ exportCredits: credits, clipCount: allClips.length }); + if (!budget.ok) return await fail("EXPORT_BUDGET", budget.reason); + // Trim to the budget: clips beyond the ceiling stay in Crayo untouched. + let remaining = budget.count; + for (const seg of state.segments) { + seg.clips = (seg.clips ?? []).map((clip) => + remaining-- > 0 + ? clip + : { + ...clip, + status: "failed" as const, + error: "Over the per-run export ceiling; still available in Crayo.", + }, + ); + } + state.exportBudget = { count: budget.count, credits }; + state.phase = "exporting"; + await progress( + runId, + state, + `Exporting ${budget.count} clip(s) to the Library${credits != null ? ` (${credits} export credits available)` : ""}.`, + ); + } + await saveState(runId, run.outputs, { ...state, lockUntil: null }); + return "advanced"; + } + + // ---- Export phase: push each Crayo clip project into the Library ---- + if (state.phase === "exporting") { + const { EXPORT_CONCURRENCY } = await import("@/lib/clip-export"); + const { exportClipStep, defaultClipExportDeps } = await import( + "@/lib/server/clip-export.server" + ); + const deps = defaultClipExportDeps(); + const ctx = { + actorId: state.actorId, + clientId: state.clientId, + sourceUrl: state.url, + tags: ["autoclip"], + }; + const active = state.segments + .flatMap((seg) => seg.clips ?? []) + .filter((c) => c.status === "pending" || c.status === "exporting"); + const batch = active.slice(0, EXPORT_CONCURRENCY); + for (const clip of batch) { + const before = clip.status; + const next = await exportClipStep(clip, ctx, deps); + Object.assign(clip, next); + const all = state.segments.flatMap((seg) => seg.clips ?? []); + const idx = all.indexOf(clip) + 1; + if (next.status === "stored" && before !== "stored") { + await progress( + runId, + state, + `Stored clip ${idx} of ${all.length} (${Math.round((next.bytes ?? 0) / 1048576)} MB): ${next.title}`, + ); + } else if (next.status === "failed" && before !== "failed") { + await progress(runId, state, `Clip ${idx} of ${all.length} failed: ${next.error ?? "unknown error"}`); + } else if (next.status === "exporting" && before === "pending") { + await progress(runId, state, `Exporting clip ${idx} of ${all.length}: ${next.title}`); + } + } + const all = state.segments.flatMap((seg) => seg.clips ?? []); + if (all.every((c) => c.status === "stored" || c.status === "failed")) { state.phase = "done"; state.lockUntil = null; - const clips = state.segments.flatMap((seg) => seg.clips ?? []); - const summary = `AutoClip finished: ${clips.length} clip(s) across ${state.segments.length} segment(s) of “${state.probe?.title ?? "video"}”. ${clips + const stored = all.filter((c) => c.status === "stored"); + const failed = all.filter((c) => c.status === "failed"); + const summary = `AutoClip finished: ${stored.length} clip(s) in the Library${failed.length ? `, ${failed.length} failed` : ""} from “${state.probe?.title ?? "video"}”. ${stored .map((c) => c.title) .slice(0, 8) .join(" · ")}`; @@ -658,11 +730,12 @@ export async function tickMediaFetchJob( outputs: { ...(run.outputs ?? {}), mediaFetch: state as unknown as JsonValue, - autoclips: state.segments.map((seg) => ({ - segment: seg.index, - assetId: seg.assetId ?? null, - autoclipId: seg.autoclipId ?? null, - clips: seg.clips ?? [], + libraryClips: all.map((c) => ({ + title: c.title, + projectId: c.projectId, + assetId: c.assetId, + status: c.status, + error: c.error, })) as unknown as JsonValue, }, }); From 75d04fe08378c177fa67035a9ff2bcfa6da1aa0e Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:07:40 +0000 Subject: [PATCH 05/14] style: prettier formatting on Task 4/5 files --- src/lib/server/clip-export.server.test.ts | 133 ++++++++++++++--- src/lib/server/clip-export.server.ts | 134 ++++++++++++++---- src/lib/server/library-pipeline.server.ts | 124 ++++++++++++---- src/lib/server/library-storage.server.test.ts | 10 +- 4 files changed, 325 insertions(+), 76 deletions(-) diff --git a/src/lib/server/clip-export.server.test.ts b/src/lib/server/clip-export.server.test.ts index f0ebac5..73b691d 100644 --- a/src/lib/server/clip-export.server.test.ts +++ b/src/lib/server/clip-export.server.test.ts @@ -1,25 +1,78 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { exportClipStep, readExportCredits, type ClipExportDeps, type ClipExportState } from "./clip-export.server.ts"; +import { + exportClipStep, + readExportCredits, + type ClipExportDeps, + type ClipExportState, +} from "./clip-export.server.ts"; -function fakeDeps(over: Partial & { exportStatuses?: string[] } = {}): ClipExportDeps & { calls: string[] } { +function fakeDeps( + over: Partial & { exportStatuses?: string[] } = {}, +): ClipExportDeps & { calls: string[] } { const calls: string[] = []; const statuses = [...(over.exportStatuses ?? ["processing", "completed"])]; return { calls, crayo: { - async exportProject(id) { calls.push(`export:${id}`); return { export: { id: "exp_1", status: "queued" } }; }, - async getExport(id) { calls.push(`poll:${id}`); const s = statuses.shift() ?? "completed"; return { export: { id, status: s, video_url: "https://cdn-crayo.com/c.mp4", file_size: 1000 } }; }, - async getAccount() { return { credits: { export: 7 } }; }, + async exportProject(id) { + calls.push(`export:${id}`); + return { export: { id: "exp_1", status: "queued" } }; + }, + async getExport(id) { + calls.push(`poll:${id}`); + const s = statuses.shift() ?? "completed"; + return { + export: { id, status: s, video_url: "https://cdn-crayo.com/c.mp4", file_size: 1000 }, + }; + }, + async getAccount() { + return { credits: { export: 7 } }; + }, }, findByExternalRef: over.findByExternalRef ?? (async () => null), - download: over.download ?? (async (url) => { calls.push(`download:${url}`); return { path: "/tmp/fake.mp4", bytes: 1000, mime: "video/mp4", cleanup: async () => { calls.push("cleanup"); } }; }), - ingest: over.ingest ?? (async () => { calls.push("ingest"); return { assetId: "asset_1" }; }), - attachThumbnail: over.attachThumbnail ?? (async () => { calls.push("thumb"); }), + download: + over.download ?? + (async (url) => { + calls.push(`download:${url}`); + return { + path: "/tmp/fake.mp4", + bytes: 1000, + mime: "video/mp4", + cleanup: async () => { + calls.push("cleanup"); + }, + }; + }), + ingest: + over.ingest ?? + (async () => { + calls.push("ingest"); + return { assetId: "asset_1" }; + }), + attachThumbnail: + over.attachThumbnail ?? + (async () => { + calls.push("thumb"); + }), }; } -const base = (): ClipExportState => ({ projectId: "p1", title: "Clip", thumbnailUrl: "https://cdn-crayo.com/t.jpg", exportId: null, status: "pending", assetId: null, error: null, bytes: null }); -const ctx = { actorId: "u1", clientId: null, sourceUrl: "https://www.youtube.com/watch?v=x", tags: ["autoclip"] }; +const base = (): ClipExportState => ({ + projectId: "p1", + title: "Clip", + thumbnailUrl: "https://cdn-crayo.com/t.jpg", + exportId: null, + status: "pending", + assetId: null, + error: null, + bytes: null, +}); +const ctx = { + actorId: "u1", + clientId: null, + sourceUrl: "https://www.youtube.com/watch?v=x", + tags: ["autoclip"], +}; test("pending → exporting starts the export once", async () => { const deps = fakeDeps(); @@ -32,13 +85,21 @@ test("pending → exporting starts the export once", async () => { test("exporting polls until done, then downloads, ingests, attaches thumbnail, cleans up", async () => { const deps = fakeDeps(); let s = await exportClipStep(base(), ctx, deps); - s = await exportClipStep(s, ctx, deps); // processing + s = await exportClipStep(s, ctx, deps); // processing assert.equal(s.status, "exporting"); - s = await exportClipStep(s, ctx, deps); // completed + s = await exportClipStep(s, ctx, deps); // completed assert.equal(s.status, "stored"); assert.equal(s.assetId, "asset_1"); assert.equal(s.bytes, 1000); - assert.deepEqual(deps.calls, ["export:p1", "poll:exp_1", "poll:exp_1", "download:https://cdn-crayo.com/c.mp4", "ingest", "thumb", "cleanup"]); + assert.deepEqual(deps.calls, [ + "export:p1", + "poll:exp_1", + "poll:exp_1", + "download:https://cdn-crayo.com/c.mp4", + "ingest", + "thumb", + "cleanup", + ]); }); test("already-stored project is a no-op via external ref", async () => { @@ -58,7 +119,12 @@ test("failed export marks only this clip failed with the provider message", asyn }); test("storage failure cleans up and fails the clip", async () => { - const deps = fakeDeps({ exportStatuses: ["completed"], ingest: async () => { throw new Error("SUPABASE_DOWN"); } }); + const deps = fakeDeps({ + exportStatuses: ["completed"], + ingest: async () => { + throw new Error("SUPABASE_DOWN"); + }, + }); let s = await exportClipStep(base(), ctx, deps); s = await exportClipStep(s, ctx, deps); assert.equal(s.status, "failed"); @@ -68,7 +134,14 @@ test("storage failure cleans up and fails the clip", async () => { test("oversized export is refused before download", async () => { const deps = fakeDeps(); - deps.crayo.getExport = async (id) => ({ export: { id, status: "completed", video_url: "https://cdn-crayo.com/big.mp4", file_size: 600 * 1024 * 1024 } }); + deps.crayo.getExport = async (id) => ({ + export: { + id, + status: "completed", + video_url: "https://cdn-crayo.com/big.mp4", + file_size: 600 * 1024 * 1024, + }, + }); let s = await exportClipStep(base(), ctx, deps); s = await exportClipStep(s, ctx, deps); assert.equal(s.status, "failed"); @@ -77,6 +150,32 @@ test("oversized export is refused before download", async () => { }); test("readExportCredits tolerates a failing account call", async () => { - assert.equal(await readExportCredits({ async exportProject() { return null; }, async getExport() { return null; }, async getAccount() { return { credits: { export: 7 } }; } }), 7); - assert.equal(await readExportCredits({ async exportProject() { return null; }, async getExport() { return null; }, async getAccount() { throw new Error("x"); } }), null); + assert.equal( + await readExportCredits({ + async exportProject() { + return null; + }, + async getExport() { + return null; + }, + async getAccount() { + return { credits: { export: 7 } }; + }, + }), + 7, + ); + assert.equal( + await readExportCredits({ + async exportProject() { + return null; + }, + async getExport() { + return null; + }, + async getAccount() { + throw new Error("x"); + }, + }), + null, + ); }); diff --git a/src/lib/server/clip-export.server.ts b/src/lib/server/clip-export.server.ts index e5e628a..7724001 100644 --- a/src/lib/server/clip-export.server.ts +++ b/src/lib/server/clip-export.server.ts @@ -25,21 +25,38 @@ export type ClipExportState = { export type ClipExportDeps = { crayo: CrayoExportClient; findByExternalRef(ref: string): Promise<{ id: string } | null>; - download(url: string, maxBytes: number): Promise<{ path: string; bytes: number; mime: string; cleanup(): Promise }>; + download( + url: string, + maxBytes: number, + ): Promise<{ path: string; bytes: number; mime: string; cleanup(): Promise }>; ingest(input: { - path: string; bytes: number; mime: string; title: string; externalRef: string; sourceRef: string; - actorId: string; clientId: string | null; tags: string[]; + path: string; + bytes: number; + mime: string; + title: string; + externalRef: string; + sourceRef: string; + actorId: string; + clientId: string | null; + tags: string[]; }): Promise<{ assetId: string }>; attachThumbnail(assetId: string, thumbnailUrl: string): Promise; }; -export type ClipExportContext = { actorId: string; clientId: string | null; sourceUrl: string; tags: string[] }; +export type ClipExportContext = { + actorId: string; + clientId: string | null; + sourceUrl: string; + tags: string[]; +}; const POLL_MS = 3000; const MAX_POLLS = 60; function message(error: unknown): string { - return (error instanceof Error ? error.message : String(error)).replace(/https?:\/\/\S+/g, "").slice(0, 300); + return (error instanceof Error ? error.message : String(error)) + .replace(/https?:\/\/\S+/g, "") + .slice(0, 300); } export async function readExportCredits(crayo: CrayoExportClient): Promise { @@ -53,7 +70,11 @@ export async function readExportCredits(crayo: CrayoExportClient): Promise { +export async function exportClipStep( + state: ClipExportState, + ctx: ClipExportContext, + deps: ClipExportDeps, +): Promise { if (state.status === "stored" || state.status === "failed") return state; const ref = clipExternalRef(state.projectId); try { @@ -61,23 +82,37 @@ export async function exportClipStep(state: ClipExportState, ctx: ClipExportCont const existing = await deps.findByExternalRef(ref); if (existing) return { ...state, status: "stored", assetId: existing.id, error: null }; const queued = readExportPayload(await deps.crayo.exportProject(state.projectId)); - if (!queued.exportId) return { ...state, status: "failed", error: "Crayo did not return an export id." }; + if (!queued.exportId) + return { ...state, status: "failed", error: "Crayo did not return an export id." }; return { ...state, status: "exporting", exportId: queued.exportId }; } // exporting const polled = readExportPayload(await deps.crayo.getExport(state.exportId!)); if (polled.status === "pending") return state; - if (polled.status === "failed" || !polled.url) return { ...state, status: "failed", error: "Crayo export failed." }; + if (polled.status === "failed" || !polled.url) + return { ...state, status: "failed", error: "Crayo export failed." }; if (polled.bytes != null && polled.bytes > BUCKET_OBJECT_LIMIT_BYTES) { - return { ...state, status: "failed", error: `Export is ${Math.round(polled.bytes / 1048576)} MB; the library bucket allows 512 MB per file.` }; + return { + ...state, + status: "failed", + error: `Export is ${Math.round(polled.bytes / 1048576)} MB; the library bucket allows 512 MB per file.`, + }; } const file = await deps.download(polled.url, BUCKET_OBJECT_LIMIT_BYTES); try { const { assetId } = await deps.ingest({ - path: file.path, bytes: file.bytes, mime: file.mime, title: state.title, externalRef: ref, - sourceRef: ctx.sourceUrl, actorId: ctx.actorId, clientId: ctx.clientId, tags: ctx.tags, + path: file.path, + bytes: file.bytes, + mime: file.mime, + title: state.title, + externalRef: ref, + sourceRef: ctx.sourceUrl, + actorId: ctx.actorId, + clientId: ctx.clientId, + tags: ctx.tags, }); - if (state.thumbnailUrl) await deps.attachThumbnail(assetId, state.thumbnailUrl).catch(() => {}); + if (state.thumbnailUrl) + await deps.attachThumbnail(assetId, state.thumbnailUrl).catch(() => {}); return { ...state, status: "stored", assetId, bytes: file.bytes, error: null }; } finally { await file.cleanup(); @@ -89,22 +124,48 @@ export async function exportClipStep(state: ClipExportState, ctx: ClipExportCont /** Synchronous convenience for callers that can wait (direct-file /autoclip, /export). */ export async function exportClipToLibrary( - input: { projectId: string; title: string; thumbnailUrl: string | null; actorId: string; clientId: string | null; sourceUrl: string; tags?: string[] }, + input: { + projectId: string; + title: string; + thumbnailUrl: string | null; + actorId: string; + clientId: string | null; + sourceUrl: string; + tags?: string[]; + }, deps: ClipExportDeps = defaultClipExportDeps(), ): Promise { - let state: ClipExportState = { projectId: input.projectId, title: input.title, thumbnailUrl: input.thumbnailUrl, exportId: null, status: "pending", assetId: null, error: null, bytes: null }; - const ctx: ClipExportContext = { actorId: input.actorId, clientId: input.clientId, sourceUrl: input.sourceUrl, tags: input.tags ?? ["crayo", "autoclip"] }; + let state: ClipExportState = { + projectId: input.projectId, + title: input.title, + thumbnailUrl: input.thumbnailUrl, + exportId: null, + status: "pending", + assetId: null, + error: null, + bytes: null, + }; + const ctx: ClipExportContext = { + actorId: input.actorId, + clientId: input.clientId, + sourceUrl: input.sourceUrl, + tags: input.tags ?? ["crayo", "autoclip"], + }; for (let i = 0; i < MAX_POLLS + 2; i += 1) { const next = await exportClipStep(state, ctx, deps); if (next.status === "stored" || next.status === "failed") return next; - if (next.status === "exporting" && state.status === "exporting") await new Promise((r) => setTimeout(r, POLL_MS)); + if (next.status === "exporting" && state.status === "exporting") + await new Promise((r) => setTimeout(r, POLL_MS)); state = next; } return { ...state, status: "failed", error: "Crayo export is still processing." }; } /** Stream an https URL to a temp file with a byte ceiling. */ -export async function downloadToTempFile(url: string, maxBytes: number): Promise<{ path: string; bytes: number; mime: string; cleanup(): Promise }> { +export async function downloadToTempFile( + url: string, + maxBytes: number, +): Promise<{ path: string; bytes: number; mime: string; cleanup(): Promise }> { const { mkdtemp, rm } = await import("node:fs/promises"); const { createWriteStream } = await import("node:fs"); const { tmpdir } = await import("node:os"); @@ -113,7 +174,11 @@ export async function downloadToTempFile(url: string, maxBytes: number): Promise const { Readable, Transform } = await import("node:stream"); const { isTrustedLibraryUrl } = await import("@/lib/server/library-pipeline.server"); if (!isTrustedLibraryUrl(url)) throw new Error("UNTRUSTED_URL"); - const response = await fetch(url, { redirect: "follow", headers: { "User-Agent": "ClippyAdmin/1.0" }, signal: AbortSignal.timeout(10 * 60 * 1000) }); + const response = await fetch(url, { + redirect: "follow", + headers: { "User-Agent": "ClippyAdmin/1.0" }, + signal: AbortSignal.timeout(10 * 60 * 1000), + }); if (!response.ok || !response.body) throw new Error("UNTRUSTED_URL"); const declared = Number(response.headers.get("content-length") ?? 0); if (declared > maxBytes) throw new Error("MEDIA_TOO_LARGE"); @@ -127,7 +192,9 @@ export async function downloadToTempFile(url: string, maxBytes: number): Promise else cb(null, chunk); }, }); - const cleanup = async () => { await rm(dir, { recursive: true, force: true }); }; + const cleanup = async () => { + await rm(dir, { recursive: true, force: true }); + }; try { await pipeline(Readable.fromWeb(response.body as never), limiter, createWriteStream(path)); } catch (error) { @@ -141,29 +208,44 @@ export async function downloadToTempFile(url: string, maxBytes: number): Promise export function defaultClipExportDeps(): ClipExportDeps { return { crayo: { - exportProject: async (id) => (await import("@/lib/server/crayo.server")).crayoExportProject(id), + exportProject: async (id) => + (await import("@/lib/server/crayo.server")).crayoExportProject(id), getExport: async (id) => (await import("@/lib/server/crayo.server")).crayoGetExport(id), getAccount: async () => (await import("@/lib/server/crayo.server")).crayoGetAccount(), }, - findByExternalRef: async (ref) => (await import("@/lib/server/library.server")).findAssetByExternalRef(ref), + findByExternalRef: async (ref) => + (await import("@/lib/server/library.server")).findAssetByExternalRef(ref), download: downloadToTempFile, ingest: async (input) => { const { ingestFile } = await import("@/lib/server/library-pipeline.server"); const { asset } = await ingestFile({ - actorId: input.actorId, clientId: input.clientId, title: input.title, filePath: input.path, - mimeHint: input.mime, filename: `${input.title}.mp4`, source: "AGENT", sourceRef: input.sourceRef.slice(0, 500), - externalRef: input.externalRef, tags: ["crayo", ...input.tags], note: "crayo export", + actorId: input.actorId, + clientId: input.clientId, + title: input.title, + filePath: input.path, + mimeHint: input.mime, + filename: `${input.title}.mp4`, + source: "AGENT", + sourceRef: input.sourceRef.slice(0, 500), + externalRef: input.externalRef, + tags: ["crayo", ...input.tags], + note: "crayo export", }); return { assetId: asset.id }; }, attachThumbnail: async (assetId, thumbnailUrl) => { - const { isTrustedLibraryUrl, attachThumbnail } = await import("@/lib/server/library-pipeline.server"); + const { isTrustedLibraryUrl, attachThumbnail } = + await import("@/lib/server/library-pipeline.server"); if (!isTrustedLibraryUrl(thumbnailUrl)) return; const response = await fetch(thumbnailUrl, { signal: AbortSignal.timeout(20_000) }); if (!response.ok) return; const bytes = Buffer.from(await response.arrayBuffer()); if (bytes.length > 5 * 1024 * 1024) return; - await attachThumbnail({ assetId, bytes, mimeHint: response.headers.get("content-type") ?? "image/jpeg" }); + await attachThumbnail({ + assetId, + bytes, + mimeHint: response.headers.get("content-type") ?? "image/jpeg", + }); }, }; } diff --git a/src/lib/server/library-pipeline.server.ts b/src/lib/server/library-pipeline.server.ts index 433f8b8..d069867 100644 --- a/src/lib/server/library-pipeline.server.ts +++ b/src/lib/server/library-pipeline.server.ts @@ -65,7 +65,12 @@ function nowIso() { return new Date().toISOString(); } -async function audit(actorId: string, action: string, entityId: string, result: "ok" | "error" = "ok") { +async function audit( + actorId: string, + action: string, + entityId: string, + result: "ok" | "error" = "ok", +) { try { await writeAuditLog({ requestId: `${action}:${entityId}`, @@ -135,9 +140,7 @@ async function probeFile(path: string): Promise { const text = `${result.stdout}\n${result.stderr}`; const dur = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(text); const dim = /Stream #.*Video:.*\s(\d{2,5})x(\d{2,5})/.exec(text); - const durationSec = dur - ? Number(dur[1]) * 3600 + Number(dur[2]) * 60 + Number(dur[3]) - : null; + const durationSec = dur ? Number(dur[1]) * 3600 + Number(dur[2]) * 60 + Number(dur[3]) : null; return { durationSec: Number.isFinite(durationSec) ? durationSec : null, width: dim ? Number(dim[1]) : null, @@ -208,7 +211,8 @@ export async function ingestBytes(input: { const ext = extFromMime(mime, input.filename); const key = makeStorageKey(assetId, versionId, ext); await writeLibraryBytes(key, input.bytes); - const title = sanitizeText(input.title || input.filename || "Untitled").slice(0, 160) || "Untitled"; + const title = + sanitizeText(input.title || input.filename || "Untitled").slice(0, 160) || "Untitled"; await insertAsset({ id: assetId, client_id: input.clientId, @@ -253,7 +257,10 @@ async function hashFile(path: string): Promise { const { createReadStream } = await import("node:fs"); return new Promise((resolve, reject) => { const hash = createHash("sha256"); - createReadStream(path).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest("hex"))); + createReadStream(path) + .on("data", (chunk) => hash.update(chunk)) + .on("error", reject) + .on("end", () => resolve(hash.digest("hex"))); }); } @@ -285,7 +292,8 @@ export async function ingestFile(input: { const ext = extFromMime(mime, input.filename); const key = makeStorageKey(assetId, versionId, ext); const storageKey = await writeLibraryFile(key, input.filePath, mime); - const title = sanitizeText(input.title || input.filename || "Untitled").slice(0, 160) || "Untitled"; + const title = + sanitizeText(input.title || input.filename || "Untitled").slice(0, 160) || "Untitled"; await insertAsset({ id: assetId, client_id: input.clientId, @@ -314,13 +322,25 @@ export async function ingestFile(input: { }); await finalizeProbeFromPath(assetId, versionId, input.filePath, mime, byteSize, checksum); await audit(input.actorId, "library.ingest", assetId); - emitAutonomyEvent({ type: "library.asset.ready", entityType: "media_asset", entityId: assetId, data: { source: input.source, kind, clientId: input.clientId } }); + emitAutonomyEvent({ + type: "library.asset.ready", + entityType: "media_asset", + entityId: assetId, + data: { source: input.source, kind, clientId: input.clientId }, + }); const asset = await getAsset(assetId); if (!asset) throw new Error("ASSET_MISSING"); return { asset, duplicate: false }; } -async function finalizeProbeFromPath(assetId: string, versionId: string, path: string, mime: string, byteSize: number, checksum: string) { +async function finalizeProbeFromPath( + assetId: string, + versionId: string, + path: string, + mime: string, + byteSize: number, + checksum: string, +) { try { await assertReadableMedia(path); } catch { @@ -342,7 +362,11 @@ async function finalizeProbeFromPath(assetId: string, versionId: string, path: s } /** Store a small thumbnail as an extra version row and point the asset at it. */ -export async function attachThumbnail(input: { assetId: string; bytes: Buffer; mimeHint: string }): Promise { +export async function attachThumbnail(input: { + assetId: string; + bytes: Buffer; + mimeHint: string; +}): Promise { const { writeLibraryBytes } = await import("@/lib/server/library-storage.server"); const mime = sniffMime(input.bytes, input.mimeHint, "thumb.jpg"); const versionId = libraryNewId(); @@ -409,10 +433,17 @@ function hostAllowed(host: string): boolean { if (isBlockedFetchHost(h)) return false; if (URL_HOST_ALLOW.includes(h)) return true; if (h.endsWith(".twitch.tv") || h.endsWith(".jtvnw.net")) return true; - if (h.endsWith(".tiktokcdn.com") || h.endsWith(".tiktok.com") || h.endsWith(".muscdn.com")) return true; + if (h.endsWith(".tiktokcdn.com") || h.endsWith(".tiktok.com") || h.endsWith(".muscdn.com")) + return true; if (h.endsWith(".cdninstagram.com") || h.endsWith(".fbcdn.net")) return true; if (h.endsWith(".googleusercontent.com") || h.endsWith(".ggpht.com")) return true; - if (h === "cdn-crayo.com" || h.endsWith(".cdn-crayo.com") || h === "crayo.ai" || h.endsWith(".crayo.ai")) return true; + if ( + h === "cdn-crayo.com" || + h.endsWith(".cdn-crayo.com") || + h === "crayo.ai" || + h.endsWith(".crayo.ai") + ) + return true; return false; } @@ -472,7 +503,8 @@ export async function ingestFromUrl(input: { } catch { throw new Error("UNTRUSTED_URL"); } - if (parsed.protocol !== "https:" || parsed.username || parsed.password) throw new Error("UNTRUSTED_URL"); + if (parsed.protocol !== "https:" || parsed.username || parsed.password) + throw new Error("UNTRUSTED_URL"); if (!hostAllowed(parsed.hostname.replace(/^\[|\]$/g, ""))) throw new Error("UNTRUSTED_URL"); const len = Number(response.headers.get("content-length") ?? 0); if (len > max) throw new Error("MEDIA_TOO_LARGE"); @@ -496,7 +528,8 @@ export async function ingestFromUrl(input: { reader.releaseLock(); } const buf = Buffer.concat(chunks, written); - const mime = response.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream"; + const mime = + response.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream"; const name = parsed.pathname.split("/").pop() || "import"; return ingestBytes({ actorId: input.actorId, @@ -510,7 +543,10 @@ export async function ingestFromUrl(input: { tags: input.tags, }); } catch (error) { - if (error instanceof Error && (error.message === "MEDIA_TOO_LARGE" || error.message === "UNTRUSTED_URL")) { + if ( + error instanceof Error && + (error.message === "MEDIA_TOO_LARGE" || error.message === "UNTRUSTED_URL") + ) { throw error; } throw new Error("UNTRUSTED_URL"); @@ -644,7 +680,10 @@ export async function ingestThumbnailMessage(input: { } function parseSrt(raw: string): CaptionCue[] { - const blocks = raw.replace(/^\uFEFF/, "").replace(/\r/g, "").split(/\n\n+/); + const blocks = raw + .replace(/^\uFEFF/, "") + .replace(/\r/g, "") + .split(/\n\n+/); const cues: CaptionCue[] = []; for (const block of blocks) { const lines = block.split("\n").filter(Boolean); @@ -654,7 +693,10 @@ function parseSrt(raw: string): CaptionCue[] { const match = /(\d+):(\d+):(\d+)[,.](\d+)\s*-->\s*(\d+):(\d+):(\d+)[,.](\d+)/.exec(timeLine); if (!match) continue; const toMs = (h: string, m: string, s: string, f: string) => - Number(h) * 3_600_000 + Number(m) * 60_000 + Number(s) * 1000 + Number(f.padEnd(3, "0").slice(0, 3)); + Number(h) * 3_600_000 + + Number(m) * 60_000 + + Number(s) * 1000 + + Number(f.padEnd(3, "0").slice(0, 3)); const text = lines .slice(lines.indexOf(timeLine) + 1) .join(" ") @@ -678,7 +720,11 @@ function wordsToCues(words: Array<{ text: string; start: number; end: number }>) cues.push({ startMs: Math.round(bucket[0].start * 1000), endMs: Math.round(bucket[bucket.length - 1].end * 1000), - text: bucket.map((w) => w.text).join(" ").replace(/\s+/g, " ").trim(), + text: bucket + .map((w) => w.text) + .join(" ") + .replace(/\s+/g, " ") + .trim(), }); bucket = []; }; @@ -717,7 +763,11 @@ export async function generateCaptions(input: { status: "TRANSCRIBING", engine: "XAI_OR_PROVIDER", }); - void runTranscription({ trackId: track.id, storageKey: version.storageKey, language: input.language ?? "en" }); + void runTranscription({ + trackId: track.id, + storageKey: version.storageKey, + language: input.language ?? "en", + }); await audit(input.actorId, "library.generate_captions", track.id); return track; } @@ -834,14 +884,20 @@ export async function saveCues(input: { return updated; } -export async function captionExport(trackId: string, format: "SRT" | "VTT"): Promise<{ filename: string; body: string }> { +export async function captionExport( + trackId: string, + format: "SRT" | "VTT", +): Promise<{ filename: string; body: string }> { const track = await getCaption(trackId); if (!track || track.status !== "READY") throw new Error("CAPTION_NOT_READY"); const body = format === "VTT" ? cuesToVtt(track.cues) : cuesToSrt(track.cues); return { filename: `captions.${format.toLowerCase()}`, body }; } -function presetSize(preset: RenderPreset, options: RenderOptions): { width: number; height: number } { +function presetSize( + preset: RenderPreset, + options: RenderOptions, +): { width: number; height: number } { if (preset === "CUSTOM") { return { width: options.customWidth || options.maxWidth || 1080, @@ -899,7 +955,8 @@ export async function queueRender(input: { export async function cancelRender(input: { actorId: string; jobId: string }): Promise { const job = await getRender(input.jobId); if (!job) throw new Error("JOB_MISSING"); - if (job.status === "SUCCEEDED" || job.status === "FAILED" || job.status === "CANCELED") return job; + if (job.status === "SUCCEEDED" || job.status === "FAILED" || job.status === "CANCELED") + return job; cancelFlags.add(job.id); await patchRender(job.id, { status: "CANCELED", @@ -978,7 +1035,8 @@ async function runRenderJob(jobId: string): Promise { let srtPath: string | null = null; if (job.options.burnInCaptions && job.captionTrackId) { const track = await getCaption(job.captionTrackId); - if (!track || track.status !== "READY" || track.cues.length === 0) throw new Error("CAPTION_NOT_READY"); + if (!track || track.status !== "READY" || track.cues.length === 0) + throw new Error("CAPTION_NOT_READY"); srtPath = join(dir, "burn.srt"); await writeFile(srtPath, cuesToSrt(track.cues), "utf8"); const escaped = srtPath.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "\\'"); @@ -997,7 +1055,8 @@ async function runRenderJob(jobId: string): Promise { args.push("-t", String(job.options.targetMaxDurationSec)); } args.push("-vf", vf.join(",")); - if (job.options.loudnorm && asset.kind === "VIDEO") args.push("-af", "loudnorm=I=-16:TP=-1.5:LRA=11"); + if (job.options.loudnorm && asset.kind === "VIDEO") + args.push("-af", "loudnorm=I=-16:TP=-1.5:LRA=11"); args.push( "-c:v", "libx264", @@ -1080,16 +1139,16 @@ async function runRenderJob(jobId: string): Promise { data: { error: code }, }); void import("@/lib/server/safety-hooks.server") - .then((mod) => - mod.onRenderFailed({ jobId, error: code, actorId: job.createdBy ?? null }), - ) + .then((mod) => mod.onRenderFailed({ jobId, error: code, actorId: job.createdBy ?? null })) .catch(() => {}); } finally { await rm(dir, { recursive: true, force: true }); } } -export async function testRender(actorId: string): Promise<{ ok: boolean; jobId?: string; message: string }> { +export async function testRender( + actorId: string, +): Promise<{ ok: boolean; jobId?: string; message: string }> { const settings = await readMediaSettings(); if (!settings.ffmpegAvailable) { return { ok: false, message: "FFmpeg is not available on this worker." }; @@ -1153,7 +1212,8 @@ export async function resolvePublishAsset(input: { platforms?: string[]; }): Promise<{ asset: LibraryAsset; mediaUrl: string | null; fileBytes?: Buffer; mime?: string }> { const asset = await getAsset(input.mediaAssetId); - if (!asset || (asset.clientId && asset.clientId !== input.clientId)) throw new Error("ASSET_MISSING"); + if (!asset || (asset.clientId && asset.clientId !== input.clientId)) + throw new Error("ASSET_MISSING"); if (asset.status !== "READY") throw new Error("ASSET_NOT_READY"); let chosen = asset; const wantsVertical = (input.platforms ?? []).some((p) => p === "tiktok" || p === "instagram"); @@ -1186,7 +1246,11 @@ export async function resolvePublishAsset(input: { }; } -export async function archiveAsset(input: { actorId: string; assetId: string; role: "admin" | "member" }) { +export async function archiveAsset(input: { + actorId: string; + assetId: string; + role: "admin" | "member"; +}) { if (input.role !== "admin") throw new Error("Forbidden"); const asset = await getAsset(input.assetId); if (!asset) throw new Error("ASSET_MISSING"); diff --git a/src/lib/server/library-storage.server.test.ts b/src/lib/server/library-storage.server.test.ts index 9c363b3..baabf01 100644 --- a/src/lib/server/library-storage.server.test.ts +++ b/src/lib/server/library-storage.server.test.ts @@ -25,9 +25,13 @@ try { } if (!mod) { - test("library-storage.server.ts (skipped: unloadable under node --test)", { - skip: `Cannot load this module outside Vite: ${loadError?.message.split("\n")[0]}`, - }, () => {}); + test( + "library-storage.server.ts (skipped: unloadable under node --test)", + { + skip: `Cannot load this module outside Vite: ${loadError?.message.split("\n")[0]}`, + }, + () => {}, + ); } else { const { writeLibraryFile, backendFromStorageKey, storagePath } = mod; From ba02735fe86e834ecf7bae4d77d1f6b1012b0757 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:17:28 +0000 Subject: [PATCH 06/14] feat(crayo-tools): sync /autoclip and /export store mp4s in the Library --- src/lib/agent.ts | 2 + src/lib/server/crayo-tools.server.ts | 81 +++++++++++++++++++--------- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/src/lib/agent.ts b/src/lib/agent.ts index ceeeb05..49a4091 100644 --- a/src/lib/agent.ts +++ b/src/lib/agent.ts @@ -676,6 +676,8 @@ export function explainAgentToolError(code: string): string { return "Crayo never received the bytes on its signed upload URL. Retry the run."; case "EXPORT_BUDGET": return "Not enough Crayo export credits for these clips."; + case "EXPORT_FAILED": + return "Crayo could not export that project."; case "VALIDATION_ERROR": return "Crayo rejected the request: the URL could not be fetched, is not a public https media file, or the video is too short/long (AutoClip needs 1 min–3 h). No credits were spent on a rejected request."; case "UNSUPPORTED_MEDIA_TYPE": diff --git a/src/lib/server/crayo-tools.server.ts b/src/lib/server/crayo-tools.server.ts index 86cd6d0..d62c4a3 100644 --- a/src/lib/server/crayo-tools.server.ts +++ b/src/lib/server/crayo-tools.server.ts @@ -272,26 +272,47 @@ async function runAutoclip( if (!autoclipId) throw new Error("CRAYO_FAILED"); const finished = await wrap(() => crayoPollAutoclip(autoclipId)); - const clipsRaw = - finished && typeof finished === "object" && Array.isArray((finished as { clips?: unknown }).clips) - ? ((finished as { clips: unknown[] }).clips) - : []; + const { readAutoclipClips, planExportBudget } = await import("@/lib/clip-export"); + const { exportClipToLibrary, readExportCredits, defaultClipExportDeps } = await import( + "@/lib/server/clip-export.server" + ); + const found = readAutoclipClips(finished); + const deps = defaultClipExportDeps(); + const budget = planExportBudget({ + exportCredits: await readExportCredits(deps.crayo), + clipCount: found.length, + }); + if (!budget.ok) throw new CrayoToolError("EXPORT_BUDGET", budget.reason); const clips = []; - for (const clip of clipsRaw.slice(0, 20)) { - const title = pickField(clip, "title") || "AutoClip"; - const thumbnailUrl = pickField(clip, "thumbnail_url") || firstHttps(clip); - const projectId = pickField(clip, "project_id") || pickField(clip, "id"); - const library = thumbnailUrl - ? await ingestCrayoMedia(actorId, clientId, thumbnailUrl, title, ["autoclip"]) - : null; - clips.push({ - title, - projectId: projectId || null, - thumbnailUrl: thumbnailUrl || null, - library, - }); + for (const [i, clip] of found.slice(0, budget.count).entries()) { + await onProgress?.(`Exporting clip ${i + 1} of ${budget.count}: ${clip.title}`); + const result = await exportClipToLibrary( + { + projectId: clip.projectId, + title: clip.title, + thumbnailUrl: clip.thumbnailUrl, + actorId, + clientId, + sourceUrl: url, + tags: ["autoclip"], + }, + deps, + ); + clips.push(result); + await onProgress?.( + result.status === "stored" + ? `Stored clip ${i + 1} of ${budget.count} (${Math.round((result.bytes ?? 0) / 1048576)} MB).` + : `Clip ${i + 1} failed: ${result.error}`, + ); } - return { autoclipId, assetId, clips, ...(fetched ? { source: fetched } : {}) }; + const libraryClips = clips.map((c) => ({ + title: c.title, + projectId: c.projectId, + assetId: c.assetId, + status: c.status, + error: c.error, + })); + return { autoclipId, assetId, clips, libraryClips, ...(fetched ? { source: fetched } : {}) }; } export async function handleCrayoAction( @@ -369,14 +390,22 @@ export async function handleCrayoAction( case "crayo.export_project": { const id = str(payload, "projectId", "id"); if (!id) throw new Error("VALIDATION"); - const queued = await wrap(() => crayoExportProject(id)); - const exportId = pickField(queued, "id"); - if (!exportId) return queued; - try { - return await wrap(() => crayoPollExport(exportId)); - } catch { - return queued; - } + const { exportClipToLibrary } = await import("@/lib/server/clip-export.server"); + const result = await exportClipToLibrary({ + projectId: id, + title: sanitizeText(str(payload, "title")).slice(0, 160) || `Crayo project ${id}`, + thumbnailUrl: null, + actorId, + clientId: str(payload, "clientId") || null, + sourceUrl: `crayo:project:${id}`, + tags: ["export"], + }); + if (result.status === "failed") throw new CrayoToolError("EXPORT_FAILED", result.error ?? ""); + return { + libraryClips: [ + { title: result.title, projectId: id, assetId: result.assetId, status: result.status, error: null }, + ], + }; } case "crayo.get_export": { const id = str(payload, "exportId", "id"); From df992435abdd671359022d864bbf2bb66b74594b Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:19:25 +0000 Subject: [PATCH 07/14] style: prettier formatting on agent.ts and media-fetch-job.server.ts --- src/lib/agent.ts | 280 +++++++++++++++-------- src/lib/server/media-fetch-job.server.ts | 36 +-- 2 files changed, 200 insertions(+), 116 deletions(-) diff --git a/src/lib/agent.ts b/src/lib/agent.ts index 49a4091..032b02b 100644 --- a/src/lib/agent.ts +++ b/src/lib/agent.ts @@ -168,91 +168,89 @@ export const AGENT_MAX_CONCURRENT = 2; export const AGENT_STEP_RETRIES = 2; export const AGENT_PROPOSE_MIN_STEPS = 5; -export const AGENT_PRESET_COPY: Record< - AgentPreset, - { label: string; goal: string; hint: string } -> = { - "clipping-ideation-pack": { - label: "Ideation pack", - goal: "Research this client's channel (long-form only, ≥ 4 minutes). Produce 5 packaging-ready ideas: each with a working title, hook mechanism, promise/tension/payoff, spoken 3-second hook, and a ≤4-word thumbnail overlay. Then generate 3 mechanistically distinct title alternatives for the latest long-form upload. Do not duplicate existing titles. No social, no VM.", - hint: "Ideas + titles. No social, no VM.", - }, - "clipping-thumbnail-pass": { - label: "Thumbnail pass", - goal: "If a latest thumbnail image exists, vision-critique it (emotion, contrast, overlay ≤4 words, postage-stamp legibility). Then generate a fresh 16:9 4K direction whose overlay complements — not repeats — the title. One focal face/object, mandatory emotion, complementary palette.", - hint: "Vision critique + Higgsfield generation.", - }, - "clipping-full-package": { - label: "Full clip package", - goal: "End-to-end production brief for this client: research long-form (≥ 4 min), 5 ideas with hooks + overlays, 3 titles for the latest long-form, one thumbnail direction, optional stage note only if evidence exists. Write package.json. Never invent analytics. Never start the Social Machine.", - hint: "SKILL.md plan + orchestrate.py export. Never auto-starts Social.", - }, - "clipping-social-draft-distribute": { - label: "Social drafts", - goal: "Queue draft Instagram / X / TikTok posts for the latest publishable library asset (prefer 9:16). preferredRail=AUTO, mode=draft. Do not start the Social Machine. If MACHINE_STOPPED, wait for a human — do not imply start.", - hint: "create_upload_job in draft mode. MACHINE_STOPPED waits for a human.", - }, - "clipping-social-verify": { - label: "Social verify", - goal: "After an upload job, screenshot the desktop and verify compose/success UI with vision. Never type passwords, 2FA, or CAPTCHA. If needs_login, stop for a human.", - hint: "Needs a running Social Machine. Never types passwords.", - }, - "clipping-pipeline-nudge": { - label: "Pipeline nudge", - goal: "Read this client's progress (including Discord-sourced stages). Recommend the next human action with evidence. Only set_stage if notes can cite a real artifact. Never fabricate views or CTR.", - hint: "Cautious set_client_stage. No fabricated analytics.", - }, - "clipping-30d-guarantee-check": { - label: "30-day check", - goal: "Single-client 30-day views check from AnalyticsSnapshots only. Need ≥2 real snapshots to call a delta. If missing, say Insufficient data — never invent views or treat unknown as zero.", - hint: "Honest insufficient-data when snapshots are missing.", - }, - "clipping-agent-self-improve": { - label: "Self-improve", - goal: "From the last successful Agent run (≥5 tool steps), draft a pending_review skill capturing the plan and pitfalls. No secrets in SKILL.md. Do not auto-publish.", - hint: "skill_manage.create pending. Never auto-publishes.", - }, - custom: { - label: "Custom goal", - goal: "", - hint: "Describe the clipping or Crayo workflow. The planner cannot call undeclared tools.", - }, - "crayo-short": { - label: "Crayo short", - goal: "Using the connected Crayo API (Bearer key, never echo it): 1) crayo.get_account to confirm credits. 2) crayo.list_voices and pick one voice_id. 3) crayo.generate_image 9:16 for a hook still from this client's niche. 4) crayo.generate_voiceover from a 12–20s spoken hook script. 5) crayo.create_project with one scene covering the narration duration_ms, optional subtitles. 6) crayo.export_project and return the finished video URL plus thumbnail. Do not start the Social Machine. If Crayo is missing, say so and stop.", - hint: "Image + voice + project + export via api.crayo.ai. Shows the file when done.", - }, - "crayo-autoclip": { - label: "Crayo AutoClip", - goal: "Using Crayo AutoClip: import or pick a long-form https video asset (1 min–3 h). crayo.create_autoclip clip_count=5 clip_length=60 edit_level=full. Then crayo.get_autoclip until clips[] has project_id + thumbnail_url. List each clip title and thumbnail. Do not start the Social Machine. Never invent URLs.", - hint: "Long video → vertical shorts. Poll until clips are ready.", - }, - "crayo-voiceover": { - label: "Crayo voiceover", - goal: "Using Crayo: crayo.generate_voiceover with the spoken script and voice_id from the operator. Return the audio URL. Do not start the Social Machine. Never echo the API key.", - hint: "Script + voice_id → audio. Credits per second.", - }, - "crayo-image": { - label: "Crayo image", - goal: "Using Crayo: crayo.generate_image with the prompt, prefer aspect_ratio=9:16. Return the still URL. Do not start the Social Machine. Never echo the API key.", - hint: "Prompt → 9:16 still. 1 image credit.", - }, - "crayo-import": { - label: "Crayo import", - goal: "Using Crayo: crayo.import_asset a public https URL (≤100MB). Return the asset id. Do not start the Social Machine. Never echo the API key.", - hint: "Public https file into Crayo assets.", - }, - "crayo-export": { - label: "Crayo export", - goal: "Using Crayo: crayo.export_project for the given project_id, then poll until the file is ready. Return the video URL. Never invent URLs. Do not start the Social Machine.", - hint: "Queue a project render and poll.", - }, - "crayo-ingest": { - label: "Crayo ingest", - goal: "Using Crayo: crayo.ingest_to_library a Crayo CDN https URL into Filebase (source=AGENT). Reject non-Crayo hosts. Do not start the Social Machine.", - hint: "Crayo CDN file → library.", - }, -}; +export const AGENT_PRESET_COPY: Record = + { + "clipping-ideation-pack": { + label: "Ideation pack", + goal: "Research this client's channel (long-form only, ≥ 4 minutes). Produce 5 packaging-ready ideas: each with a working title, hook mechanism, promise/tension/payoff, spoken 3-second hook, and a ≤4-word thumbnail overlay. Then generate 3 mechanistically distinct title alternatives for the latest long-form upload. Do not duplicate existing titles. No social, no VM.", + hint: "Ideas + titles. No social, no VM.", + }, + "clipping-thumbnail-pass": { + label: "Thumbnail pass", + goal: "If a latest thumbnail image exists, vision-critique it (emotion, contrast, overlay ≤4 words, postage-stamp legibility). Then generate a fresh 16:9 4K direction whose overlay complements — not repeats — the title. One focal face/object, mandatory emotion, complementary palette.", + hint: "Vision critique + Higgsfield generation.", + }, + "clipping-full-package": { + label: "Full clip package", + goal: "End-to-end production brief for this client: research long-form (≥ 4 min), 5 ideas with hooks + overlays, 3 titles for the latest long-form, one thumbnail direction, optional stage note only if evidence exists. Write package.json. Never invent analytics. Never start the Social Machine.", + hint: "SKILL.md plan + orchestrate.py export. Never auto-starts Social.", + }, + "clipping-social-draft-distribute": { + label: "Social drafts", + goal: "Queue draft Instagram / X / TikTok posts for the latest publishable library asset (prefer 9:16). preferredRail=AUTO, mode=draft. Do not start the Social Machine. If MACHINE_STOPPED, wait for a human — do not imply start.", + hint: "create_upload_job in draft mode. MACHINE_STOPPED waits for a human.", + }, + "clipping-social-verify": { + label: "Social verify", + goal: "After an upload job, screenshot the desktop and verify compose/success UI with vision. Never type passwords, 2FA, or CAPTCHA. If needs_login, stop for a human.", + hint: "Needs a running Social Machine. Never types passwords.", + }, + "clipping-pipeline-nudge": { + label: "Pipeline nudge", + goal: "Read this client's progress (including Discord-sourced stages). Recommend the next human action with evidence. Only set_stage if notes can cite a real artifact. Never fabricate views or CTR.", + hint: "Cautious set_client_stage. No fabricated analytics.", + }, + "clipping-30d-guarantee-check": { + label: "30-day check", + goal: "Single-client 30-day views check from AnalyticsSnapshots only. Need ≥2 real snapshots to call a delta. If missing, say Insufficient data — never invent views or treat unknown as zero.", + hint: "Honest insufficient-data when snapshots are missing.", + }, + "clipping-agent-self-improve": { + label: "Self-improve", + goal: "From the last successful Agent run (≥5 tool steps), draft a pending_review skill capturing the plan and pitfalls. No secrets in SKILL.md. Do not auto-publish.", + hint: "skill_manage.create pending. Never auto-publishes.", + }, + custom: { + label: "Custom goal", + goal: "", + hint: "Describe the clipping or Crayo workflow. The planner cannot call undeclared tools.", + }, + "crayo-short": { + label: "Crayo short", + goal: "Using the connected Crayo API (Bearer key, never echo it): 1) crayo.get_account to confirm credits. 2) crayo.list_voices and pick one voice_id. 3) crayo.generate_image 9:16 for a hook still from this client's niche. 4) crayo.generate_voiceover from a 12–20s spoken hook script. 5) crayo.create_project with one scene covering the narration duration_ms, optional subtitles. 6) crayo.export_project and return the finished video URL plus thumbnail. Do not start the Social Machine. If Crayo is missing, say so and stop.", + hint: "Image + voice + project + export via api.crayo.ai. Shows the file when done.", + }, + "crayo-autoclip": { + label: "Crayo AutoClip", + goal: "Using Crayo AutoClip: import or pick a long-form https video asset (1 min–3 h). crayo.create_autoclip clip_count=5 clip_length=60 edit_level=full. Then crayo.get_autoclip until clips[] has project_id + thumbnail_url. List each clip title and thumbnail. Do not start the Social Machine. Never invent URLs.", + hint: "Long video → vertical shorts. Poll until clips are ready.", + }, + "crayo-voiceover": { + label: "Crayo voiceover", + goal: "Using Crayo: crayo.generate_voiceover with the spoken script and voice_id from the operator. Return the audio URL. Do not start the Social Machine. Never echo the API key.", + hint: "Script + voice_id → audio. Credits per second.", + }, + "crayo-image": { + label: "Crayo image", + goal: "Using Crayo: crayo.generate_image with the prompt, prefer aspect_ratio=9:16. Return the still URL. Do not start the Social Machine. Never echo the API key.", + hint: "Prompt → 9:16 still. 1 image credit.", + }, + "crayo-import": { + label: "Crayo import", + goal: "Using Crayo: crayo.import_asset a public https URL (≤100MB). Return the asset id. Do not start the Social Machine. Never echo the API key.", + hint: "Public https file into Crayo assets.", + }, + "crayo-export": { + label: "Crayo export", + goal: "Using Crayo: crayo.export_project for the given project_id, then poll until the file is ready. Return the video URL. Never invent URLs. Do not start the Social Machine.", + hint: "Queue a project render and poll.", + }, + "crayo-ingest": { + label: "Crayo ingest", + goal: "Using Crayo: crayo.ingest_to_library a Crayo CDN https URL into Filebase (source=AGENT). Reject non-Crayo hosts. Do not start the Social Machine.", + hint: "Crayo CDN file → library.", + }, + }; export function presetCopy(preset: string): { label: string; goal: string; hint: string } { return AGENT_PRESET_COPY[normalizePreset(preset)]; @@ -455,32 +453,116 @@ export const PRESET_PLAN_SKELETONS: Record export const CRAYO_PLAN_SKELETONS: Record = { "crayo-short": [ - { id: "run", tool: "crayo.run_short", args: {}, purpose: "Generate a 9:16 short and ingest the mp4 into the Filebase library.", successCriteria: "library.assetId or videoUrl returned." }, - { id: "finish", tool: "clipping.finish", args: { summary: "Crayo short in the library." }, purpose: "Operator-facing summary with library asset id.", successCriteria: "Summary names the asset id or honest gap." }, + { + id: "run", + tool: "crayo.run_short", + args: {}, + purpose: "Generate a 9:16 short and ingest the mp4 into the Filebase library.", + successCriteria: "library.assetId or videoUrl returned.", + }, + { + id: "finish", + tool: "clipping.finish", + args: { summary: "Crayo short in the library." }, + purpose: "Operator-facing summary with library asset id.", + successCriteria: "Summary names the asset id or honest gap.", + }, ], "crayo-autoclip": [ - { id: "run", tool: "crayo.run_autoclip", args: {}, purpose: "Import the long-form URL, AutoClip, ingest thumbnails into the library.", successCriteria: "clips[] with project_id and library asset ids." }, - { id: "finish", tool: "clipping.finish", args: { summary: "AutoClip clips in the library." }, purpose: "List clip titles and library ids.", successCriteria: "No invented URLs." }, + { + id: "run", + tool: "crayo.run_autoclip", + args: {}, + purpose: "Import the long-form URL, AutoClip, ingest thumbnails into the library.", + successCriteria: "clips[] with project_id and library asset ids.", + }, + { + id: "finish", + tool: "clipping.finish", + args: { summary: "AutoClip clips in the library." }, + purpose: "List clip titles and library ids.", + successCriteria: "No invented URLs.", + }, ], "crayo-voiceover": [ - { id: "run", tool: "crayo.generate_voiceover", args: {}, purpose: "Generate spoken audio from the script and voice_id.", successCriteria: "Audio URL or honest error." }, - { id: "finish", tool: "clipping.finish", args: { summary: "Crayo voiceover ready." }, purpose: "Operator-facing summary with audio URL.", successCriteria: "No invented URLs." }, + { + id: "run", + tool: "crayo.generate_voiceover", + args: {}, + purpose: "Generate spoken audio from the script and voice_id.", + successCriteria: "Audio URL or honest error.", + }, + { + id: "finish", + tool: "clipping.finish", + args: { summary: "Crayo voiceover ready." }, + purpose: "Operator-facing summary with audio URL.", + successCriteria: "No invented URLs.", + }, ], "crayo-image": [ - { id: "run", tool: "crayo.generate_image", args: {}, purpose: "Generate a still from the prompt.", successCriteria: "Image URL or honest error." }, - { id: "finish", tool: "clipping.finish", args: { summary: "Crayo still ready." }, purpose: "Operator-facing summary with image URL.", successCriteria: "No invented URLs." }, + { + id: "run", + tool: "crayo.generate_image", + args: {}, + purpose: "Generate a still from the prompt.", + successCriteria: "Image URL or honest error.", + }, + { + id: "finish", + tool: "clipping.finish", + args: { summary: "Crayo still ready." }, + purpose: "Operator-facing summary with image URL.", + successCriteria: "No invented URLs.", + }, ], "crayo-import": [ - { id: "run", tool: "crayo.import_asset", args: {}, purpose: "Import a public https file into Crayo assets.", successCriteria: "asset id returned." }, - { id: "finish", tool: "clipping.finish", args: { summary: "Asset imported in Crayo." }, purpose: "Name the asset id.", successCriteria: "Honest gap if missing." }, + { + id: "run", + tool: "crayo.import_asset", + args: {}, + purpose: "Import a public https file into Crayo assets.", + successCriteria: "asset id returned.", + }, + { + id: "finish", + tool: "clipping.finish", + args: { summary: "Asset imported in Crayo." }, + purpose: "Name the asset id.", + successCriteria: "Honest gap if missing.", + }, ], "crayo-export": [ - { id: "run", tool: "crayo.export_project", args: {}, purpose: "Queue the project render and poll.", successCriteria: "Video URL or still-processing export id." }, - { id: "finish", tool: "clipping.finish", args: { summary: "Crayo export finished." }, purpose: "Operator-facing summary with video URL.", successCriteria: "No invented URLs." }, + { + id: "run", + tool: "crayo.export_project", + args: {}, + purpose: "Queue the project render and poll.", + successCriteria: "Video URL or still-processing export id.", + }, + { + id: "finish", + tool: "clipping.finish", + args: { summary: "Crayo export finished." }, + purpose: "Operator-facing summary with video URL.", + successCriteria: "No invented URLs.", + }, ], "crayo-ingest": [ - { id: "run", tool: "crayo.ingest_to_library", args: {}, purpose: "Copy a Crayo CDN file into the Filebase library.", successCriteria: "library.assetId or UNTRUSTED_URL." }, - { id: "finish", tool: "clipping.finish", args: { summary: "Ingested into the library." }, purpose: "Name the library asset id.", successCriteria: "Honest gap if rejected." }, + { + id: "run", + tool: "crayo.ingest_to_library", + args: {}, + purpose: "Copy a Crayo CDN file into the Filebase library.", + successCriteria: "library.assetId or UNTRUSTED_URL.", + }, + { + id: "finish", + tool: "clipping.finish", + args: { summary: "Ingested into the library." }, + purpose: "Name the library asset id.", + successCriteria: "Honest gap if rejected.", + }, ], }; diff --git a/src/lib/server/media-fetch-job.server.ts b/src/lib/server/media-fetch-job.server.ts index 07ccc9d..d8ad54a 100644 --- a/src/lib/server/media-fetch-job.server.ts +++ b/src/lib/server/media-fetch-job.server.ts @@ -69,14 +69,7 @@ type SegmentState = { export type MediaFetchJobState = { version: 1; - phase: - | "booting" - | "downloading" - | "uploading" - | "autoclipping" - | "exporting" - | "done" - | "failed"; + phase: "booting" | "downloading" | "uploading" | "autoclipping" | "exporting" | "done" | "failed"; url: string; sandboxId: string | null; actorId: string; @@ -637,9 +630,8 @@ export async function tickMediaFetchJob( } if (state.segments.every((seg) => seg.state === "done")) { const { planExportBudget } = await import("@/lib/clip-export"); - const { readExportCredits, defaultClipExportDeps } = await import( - "@/lib/server/clip-export.server" - ); + const { readExportCredits, defaultClipExportDeps } = + await import("@/lib/server/clip-export.server"); const allClips = state.segments.flatMap((seg) => seg.clips ?? []); const credits = await readExportCredits(defaultClipExportDeps().crayo); const budget = planExportBudget({ exportCredits: credits, clipCount: allClips.length }); @@ -672,9 +664,8 @@ export async function tickMediaFetchJob( // ---- Export phase: push each Crayo clip project into the Library ---- if (state.phase === "exporting") { const { EXPORT_CONCURRENCY } = await import("@/lib/clip-export"); - const { exportClipStep, defaultClipExportDeps } = await import( - "@/lib/server/clip-export.server" - ); + const { exportClipStep, defaultClipExportDeps } = + await import("@/lib/server/clip-export.server"); const deps = defaultClipExportDeps(); const ctx = { actorId: state.actorId, @@ -699,7 +690,11 @@ export async function tickMediaFetchJob( `Stored clip ${idx} of ${all.length} (${Math.round((next.bytes ?? 0) / 1048576)} MB): ${next.title}`, ); } else if (next.status === "failed" && before !== "failed") { - await progress(runId, state, `Clip ${idx} of ${all.length} failed: ${next.error ?? "unknown error"}`); + await progress( + runId, + state, + `Clip ${idx} of ${all.length} failed: ${next.error ?? "unknown error"}`, + ); } else if (next.status === "exporting" && before === "pending") { await progress(runId, state, `Exporting clip ${idx} of ${all.length}: ${next.title}`); } @@ -760,11 +755,18 @@ export async function tickMediaFetchJob( state.lockUntil = null; console.error("[media-fetch-job] transient", runId, attempts, scrub(detail)); if (attempts >= MAX_TRANSIENT_ERRORS) { - return await fail("MEDIA_FETCH_FAILED", `Gave up after ${attempts} consecutive transient errors. Last: ${detail}`); + return await fail( + "MEDIA_FETCH_FAILED", + `Gave up after ${attempts} consecutive transient errors. Last: ${detail}`, + ); } // Surface the retry on the run (1st, 2nd, 4th, 8th… attempt) so it never looks frozen. if ((attempts & (attempts - 1)) === 0) { - await progress(runId, state, `Retrying (attempt ${attempts}) after a transient error: ${scrub(detail)}`); + await progress( + runId, + state, + `Retrying (attempt ${attempts}) after a transient error: ${scrub(detail)}`, + ); } await saveState(runId, run.outputs, state).catch(() => {}); return "idle"; From c40e9b1d5fc4e3b359fd535db2b33935f5055f5d Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:24:35 +0000 Subject: [PATCH 08/14] feat(library): download variant of the file route and signLibraryAssetsFn --- src/lib/server/library-fns.ts | 32 ++++++++++++++++++++++++++++++++ src/routes/api/library.file.ts | 11 ++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/lib/server/library-fns.ts b/src/lib/server/library-fns.ts index a727042..ca6d904 100644 --- a/src/lib/server/library-fns.ts +++ b/src/lib/server/library-fns.ts @@ -427,6 +427,38 @@ export const ingestThumbnailFn = createServerFn({ method: "POST" }) return ingestThumbnailMessage({ actorId: context.userId, messageId: data.messageId }); }); +export const signLibraryAssetsFn = createServerFn({ method: "POST" }) + .middleware([authMiddleware]) + .validator((input: unknown) => + z.object({ assetIds: z.array(z.string().min(1)).max(50) }).parse(input), + ) + .handler(async ({ context, data }) => { + await requireUser(context.userId); + const { getAsset, getVersionRow } = await import("@/lib/server/library.server"); + const { signVersionUrl, backendFromStorageKey } = await import( + "@/lib/server/library-storage.server" + ); + const rows = []; + for (const assetId of data.assetIds) { + const asset = await getAsset(assetId); + if (!asset) continue; + const version = asset.currentVersionId ? await getVersionRow(asset.currentVersionId) : null; + const previewUrl = asset.currentVersionId + ? await signVersionUrl(asset.currentVersionId).catch(() => null) + : null; + rows.push({ + assetId, + title: asset.title, + status: asset.status, + previewUrl, + downloadUrl: previewUrl ? `${previewUrl}&download=1` : null, + thumbnailUrl: asset.thumbnailUrl, + backend: backendFromStorageKey(version?.storageKey ?? ""), + }); + } + return rows; + }); + export const listClientClipsFn = createServerFn({ method: "POST" }) .middleware([authMiddleware]) .validator((input: unknown) => z.object({ clientId: z.string().min(1) }).parse(input)) diff --git a/src/routes/api/library.file.ts b/src/routes/api/library.file.ts index e484684..c3d3399 100644 --- a/src/routes/api/library.file.ts +++ b/src/routes/api/library.file.ts @@ -21,13 +21,22 @@ export const Route = createFileRoute("/api/library/file")({ } const mime = version.mimeType || "application/octet-stream"; const unsafe = /svg|xml|html|javascript/i.test(mime) || !/^(video|image|audio)\//i.test(mime); + const wantsDownload = url.searchParams.get("download") === "1"; + let disposition = unsafe ? "attachment" : "inline"; + if (wantsDownload && !unsafe) { + const { getAsset } = await import("@/lib/server/library.server"); + const asset = await getAsset(version.assetId); + const ext = (version.mimeType?.split("/")[1] ?? "bin").replace(/[^a-z0-9]/gi, "").slice(0, 5) || "bin"; + const base = (asset?.title ?? "clip").replace(/[^\w\- ]+/g, "").trim().replace(/\s+/g, "-").slice(0, 80) || "clip"; + disposition = `attachment; filename="${base}.${ext}"`; + } return new Response(new Uint8Array(bytes), { status: 200, headers: { "content-type": unsafe ? "application/octet-stream" : mime, "x-content-type-options": "nosniff", "cache-control": "private, max-age=60", - "content-disposition": unsafe ? "attachment" : "inline", + "content-disposition": disposition, }, }); }, From 61357f61b32c4849c4a171acbcb32625c51d5fe5 Mon Sep 17 00:00:00 2001 From: SomeRandmGuyy <127457986+SomeRandmGuyy@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:37:19 +0000 Subject: [PATCH 09/14] feat(ui): clip preview, Download and Retry in Agent results and Library --- src/components/agent/results.tsx | 106 ++++++++++++++++++++++-- src/components/library/asset-card.tsx | 52 +++++++----- src/components/library/asset-drawer.tsx | 48 +++++++++-- src/lib/agent-results.test.ts | 32 ++++++- src/lib/agent-results.ts | 59 +++++++++++-- src/lib/server/agent-fns.ts | 41 +++++++++ 6 files changed, 295 insertions(+), 43 deletions(-) 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/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 (