diff --git a/README.md b/README.md index ecb9c20..421a814 100644 --- a/README.md +++ b/README.md @@ -45,10 +45,11 @@ Studio / Canvas / Assets -> just-in-time provider upload of the durable local reference -> BeatAPI image or video task API -> provider status polling + -> generated media copied into the local project asset directory -> local generation history and asset index ``` -The API key and storage credentials remain server-side. Project state and history live in the local SQLite database. In local SQLite mode, imported image and video files are copied immediately into the project-owned `data/project-assets//` directory and indexed in SQLite before a card is added to the Canvas. Video analysis uploads MP4/MOV input through BeatAPI's file endpoint and stores the resulting text in the same project generation history. Generated files remain at the public URLs returned by the provider and are indexed locally instead of being copied again. +The API key and storage credentials remain server-side. Project state and history live in the local SQLite database. In local SQLite mode, imported image and video files are copied immediately into the project-owned `data/project-assets//` directory and indexed in SQLite before a card is added to the Canvas. Video analysis uploads MP4/MOV input through BeatAPI's file endpoint and stores the resulting text in the same project generation history. Generated images, videos, and video covers are downloaded into the same project-owned directory before the Canvas receives their URLs; provider URLs remain metadata only. Canvas state is saved as a complete project snapshot after changes, checked again every five seconds while dirty, and flushed when the page is hidden, refreshed, or closed. A populated snapshot cannot be replaced by an unconfirmed empty snapshot. @@ -72,6 +73,7 @@ The canonical catalog lives in `src/core/effects/effect-registry.ts`. The curren | `pnpm typecheck` | Check TypeScript contracts | | `pnpm test` | Run unit and contract tests | | `pnpm i18n:check` | Validate English and Chinese messages | +| `pnpm media:localize` | Copy provider-hosted media in existing project snapshots into local project storage | | `pnpm build` | Build the production app | | `pnpm db:push` | Apply the schema during local development | | `pnpm db:generate` | Generate a reviewable D1/SQLite migration | diff --git a/package.json b/package.json index c29cf90..578c4c7 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test": "sh -c 'rg --files src -g \"*.test.ts\" -g \"*.test.tsx\" | sort | xargs node --import tsx --test'", "typecheck": "tsc --noEmit", "i18n:check": "node scripts/check-i18n.mjs", + "media:localize": "tsx scripts/with-env.ts pnpm exec tsx scripts/localize-project-media.ts", "prebuild": "node scripts/db-setup.mjs", "build": "tsx scripts/prepare-paraglide.ts && vite build && node scripts/sanitize-output.mjs", "start": "tsx scripts/with-env.ts node .output/server/index.mjs", diff --git a/scripts/localize-project-media.ts b/scripts/localize-project-media.ts new file mode 100644 index 0000000..53f5c9a --- /dev/null +++ b/scripts/localize-project-media.ts @@ -0,0 +1,138 @@ +import { isOfficialBeatApiMediaUrl } from '@/core/effects/beatapi-media-url'; +import { + createLocalProviderAssetId, + LOCAL_MEDIA_DOWNLOAD_TIMEOUT_MS, + MAX_LOCAL_IMAGE_ASSET_BYTES, + MAX_LOCAL_VIDEO_ASSET_BYTES, +} from '@/core/effects/output-storage'; +import { + LOCAL_PROJECT_ASSET_BUCKET, + LOCAL_PROJECT_ASSET_PROVIDER, + persistLocalProjectAsset, +} from '@/core/projects/local-project-assets'; +import { + loadProjectWithLatestSnapshot, + loadProjects, + saveProjectSnapshot, +} from '@/core/projects/projects'; +import { + linkProjectAsset, + recordUserAsset, +} from '@/core/workspace-lib/assets/user-assets'; +import { readResponseBodyWithLimit } from '@/lib/response-body-limit'; + +const filenameFromUrl = (url: string, type: 'image' | 'video') => { + try { + const filename = decodeURIComponent( + new URL(url).pathname.split('/').pop() || '' + ); + if (filename) return filename; + } catch { + // Fall through to a stable filename. + } + return type === 'video' ? 'migrated-video.mp4' : 'migrated-image.png'; +}; + +const localizeProject = async (projectId: string) => { + const state = await loadProjectWithLatestSnapshot({ projectId }); + if (!state) return { cards: 0, files: 0 }; + + const cardsWithRemoteMedia = state.snapshot.cards.filter( + (card) => Boolean(card.url && isOfficialBeatApiMediaUrl(card.url)) + ); + if (cardsWithRemoteMedia.length === 0) return { cards: 0, files: 0 }; + + const localizedByProviderUrl = new Map< + string, + { assetId: string; publicUrl: string } + >(); + + for (const card of cardsWithRemoteMedia) { + const providerUrl = card.url as string; + if (localizedByProviderUrl.has(providerUrl)) continue; + + const response = await fetch(providerUrl, { + signal: AbortSignal.timeout(LOCAL_MEDIA_DOWNLOAD_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`Download failed (${response.status}): ${providerUrl}`); + } + const maxBytes = + card.type === 'video' + ? MAX_LOCAL_VIDEO_ASSET_BYTES + : MAX_LOCAL_IMAGE_ASSET_BYTES; + const bytes = await readResponseBodyWithLimit(response, maxBytes); + const responseMimeType = + response.headers.get('content-type')?.split(';')[0]?.trim() || ''; + const mimeType = responseMimeType.startsWith(`${card.type}/`) + ? responseMimeType + : card.type === 'video' + ? 'video/mp4' + : 'image/png'; + const persisted = await persistLocalProjectAsset({ + projectId, + assetId: createLocalProviderAssetId(projectId, providerUrl), + filename: filenameFromUrl(providerUrl, card.type), + mimeType, + bytes, + }); + const assetId = await recordUserAsset({ + id: persisted.assetId, + type: card.type, + source: 'provider', + storageProvider: LOCAL_PROJECT_ASSET_PROVIDER, + bucket: LOCAL_PROJECT_ASSET_BUCKET, + objectKey: persisted.objectKey, + publicUrl: persisted.publicUrl, + filename: persisted.filename, + mimeType, + sizeBytes: persisted.sizeBytes, + sha256: persisted.sha256, + originProjectId: projectId, + metadata: { migratedFromProviderUrl: providerUrl }, + }); + await linkProjectAsset({ + projectId, + assetId, + role: card.kind === 'output' ? 'generated' : 'reference', + metadata: { migratedFromProviderUrl: providerUrl }, + }); + localizedByProviderUrl.set(providerUrl, { + assetId, + publicUrl: persisted.publicUrl, + }); + console.log(`localized ${card.type}: ${persisted.filename}`); + } + + const nextCards = state.snapshot.cards.map((card) => { + const localized = card.url + ? localizedByProviderUrl.get(card.url) + : undefined; + return localized + ? { ...card, url: localized.publicUrl, assetId: localized.assetId } + : card; + }); + await saveProjectSnapshot({ + projectId, + document: { ...state.snapshot, cards: nextCards }, + baseVersion: state.snapshotVersion, + }); + + return { + cards: cardsWithRemoteMedia.length, + files: localizedByProviderUrl.size, + }; +}; + +const projects = await loadProjects({ limit: 1_000 }); +let localizedCards = 0; +let localizedFiles = 0; +for (const currentProject of projects) { + const result = await localizeProject(currentProject.id); + localizedCards += result.cards; + localizedFiles += result.files; +} + +console.log( + `done: ${localizedFiles} local files now back ${localizedCards} canvas cards` +); diff --git a/src/components/beatcanvas/react-flow/react-flow-editor.tsx b/src/components/beatcanvas/react-flow/react-flow-editor.tsx index 4b17abd..0ea54ad 100644 --- a/src/components/beatcanvas/react-flow/react-flow-editor.tsx +++ b/src/components/beatcanvas/react-flow/react-flow-editor.tsx @@ -832,6 +832,7 @@ function BeatCanvasReactFlowCanvas({ zoomOnPinch nodesDraggable={interactionMode === 'select'} nodesConnectable={false} + onlyRenderVisibleElements edgesFocusable snapToGrid={isSnapToGridEnabled} snapGrid={[24, 24]} diff --git a/src/components/beatcanvas/use-beatcanvas-generation-runtime.ts b/src/components/beatcanvas/use-beatcanvas-generation-runtime.ts index 5900f88..acda3d8 100644 --- a/src/components/beatcanvas/use-beatcanvas-generation-runtime.ts +++ b/src/components/beatcanvas/use-beatcanvas-generation-runtime.ts @@ -69,7 +69,7 @@ export function useBeatCanvasGenerationRuntime({ promotePendingUploadsForDraft: ( draftId: string, generationIntentToken: string - ) => Promise; + ) => Promise>; createGenerationOutput: (params: { draftCard: CanvasDraftCard; name: string; @@ -132,10 +132,14 @@ export function useBeatCanvasGenerationRuntime({ ); const buildEffectInput = useCallback( - (draftCard: CanvasDraftCard) => + ( + draftCard: CanvasDraftCard, + referenceUrlOverrides?: Record + ) => buildGenerationEffectInput({ draftCard, canvasCards: canvasCardsRef.current, + referenceUrlOverrides, imageModels, videoModels, metadataMap, @@ -218,7 +222,10 @@ export function useBeatCanvasGenerationRuntime({ } setStatusMessage(studioT('messages.preparingAssets')); try { - await promotePendingUploadsForDraft(draftId, uploadIntentToken); + return await promotePendingUploadsForDraft( + draftId, + uploadIntentToken + ); } catch (error) { throw new Error( getDraftUploadFailureMessage({ diff --git a/src/components/beatcanvas/use-beatcanvas-upload-actions.ts b/src/components/beatcanvas/use-beatcanvas-upload-actions.ts index ae36efd..2083662 100644 --- a/src/components/beatcanvas/use-beatcanvas-upload-actions.ts +++ b/src/components/beatcanvas/use-beatcanvas-upload-actions.ts @@ -569,7 +569,7 @@ export function useBeatCanvasUploadActions({ async (draftId: string, generationIntentToken: string) => { const draftCard = canvasCardsRef.current[draftId]; if (!isCanvasDraftCard(draftCard)) { - return; + return {}; } const promotions = await promotePendingDraftReferenceUploads({ @@ -580,19 +580,17 @@ export function useBeatCanvasUploadActions({ generationIntentToken, }); + const providerUrlsByCardId: Record = {}; for (const promotion of promotions) { - updateCanvasCard(promotion.cardId, (current) => ({ - ...current, - name: current.name || promotion.uploadResult.key, - url: promotion.uploadResult.url, - })); + providerUrlsByCardId[promotion.cardId] = promotion.uploadResult.url; delete pendingUploadsRef.current[promotion.cardId]; if (promotion.objectUrl.startsWith('blob:')) { URL.revokeObjectURL(promotion.objectUrl); } } + return providerUrlsByCardId; }, - [canvasCardsRef, projectId, updateCanvasCard] + [canvasCardsRef, projectId] ); const getPendingUploadCountForDraft = useCallback( diff --git a/src/core/beatcanvas/generation-controller-output.test.ts b/src/core/beatcanvas/generation-controller-output.test.ts index bce8cc9..cd988e8 100644 --- a/src/core/beatcanvas/generation-controller-output.test.ts +++ b/src/core/beatcanvas/generation-controller-output.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { + CanvasCard, CanvasDraftCard, CanvasOutputCard, } from './canvas-types'; @@ -30,6 +31,15 @@ const makeDraft = (): CanvasDraftCard => ({ test('keeps the generation configuration and completes a separate output card', async () => { let draft = makeDraft(); + const localAsset: CanvasCard = { + ...makeDraft(), + id: 'asset:1', + kind: 'asset', + name: 'Local reference', + url: '/api/app/projects/project-1/assets/asset-1', + prompt: '', + referenceCardIds: [], + }; const outputPatches: Array> = []; let completedOutputId: string | null = null; const callOrder: string[] = []; @@ -38,13 +48,26 @@ test('keeps the generation configuration and completes a separate output card', const completed = await runDraftGeneration({ draftId: draft.id, projectId: 'project:1', - getCurrentCard: () => draft, - buildEffectInput: async () => { + getCurrentCard: (cardId) => + cardId === localAsset.id ? localAsset : draft, + buildEffectInput: async (_currentDraft, referenceUrlOverrides) => { buildCount += 1; callOrder.push(`build:${buildCount}`); + if (buildCount === 1) { + assert.equal(referenceUrlOverrides, undefined); + } else { + assert.deepEqual(referenceUrlOverrides, { + 'asset:1': 'https://media.beatapi.io/inputs/asset-1.png', + }); + } return ({ effectId: 1, - input: { prompt: draft.prompt }, + input: { + prompt: draft.prompt, + ...(referenceUrlOverrides + ? { image_urls: [referenceUrlOverrides['asset:1']] } + : {}), + }, model: { name: 'Test model' }, }) as never; }, @@ -77,9 +100,16 @@ test('keeps the generation configuration and completes a separate output card', prepareAfterPrecheck: async ({ uploadIntentToken }) => { assert.equal(uploadIntentToken, 'signed-upload-intent'); callOrder.push('upload'); + return { + 'asset:1': 'https://media.beatapi.io/inputs/asset-1.png', + }; }, generateEffectImpl: async (payload) => { assert.equal(payload.generationIntentToken, 'signed-upload-intent'); + assert.deepEqual(payload.input.image_urls, [ + 'https://media.beatapi.io/inputs/asset-1.png', + ]); + assert.equal(localAsset.url, '/api/app/projects/project-1/assets/asset-1'); callOrder.push('generate'); return ({ ok: true, diff --git a/src/core/beatcanvas/generation-controller.ts b/src/core/beatcanvas/generation-controller.ts index f1ae543..e9a37af 100644 --- a/src/core/beatcanvas/generation-controller.ts +++ b/src/core/beatcanvas/generation-controller.ts @@ -190,6 +190,7 @@ const getVideoAnalysisModel = ( type BuildGenerationEffectInputParams = { draftCard: CanvasDraftCard; canvasCards: Record; + referenceUrlOverrides?: Record; imageModels: WorkspaceModelOption[]; videoModels: WorkspaceModelOption[]; metadataMap: Record; @@ -202,6 +203,7 @@ type BuildGenerationEffectInputParams = { export const buildGenerationEffectInput = async ({ draftCard, canvasCards, + referenceUrlOverrides = {}, imageModels, videoModels, metadataMap, @@ -237,7 +239,10 @@ export const buildGenerationEffectInput = async ({ throw new Error(translate('messages.analysisVideoRequired')); } - const videoUrl = videoReferences[0]?.url; + const videoReference = videoReferences[0]; + const videoUrl = videoReference + ? referenceUrlOverrides[videoReference.id] ?? videoReference.url + : null; const input: Record = { prompt: promptValidation.trimmedPrompt, analysis_depth: draftCard.analysisDepth ?? 'standard', @@ -293,11 +298,17 @@ export const buildGenerationEffectInput = async ({ const referenceCards = draftCard.referenceCardIds .map((cardId) => canvasCards[cardId]) - .filter( - (card): card is CanvasCard => - Boolean(card?.url) && !isLocalWorkspaceMediaUrl(card.url) - ) - .map((card) => toWorkflowReferenceCard(card)); + .flatMap((card) => { + if (!card?.url) return []; + const providerUrl = referenceUrlOverrides[card.id] ?? card.url; + if (isLocalWorkspaceMediaUrl(providerUrl)) return []; + return [ + { + ...toWorkflowReferenceCard(card), + url: providerUrl, + }, + ]; + }); const referencePayload = resolveReferencePayload({ cards: referenceCards, @@ -391,7 +402,10 @@ export const buildGenerationEffectInput = async ({ hasInputSchemaField(metadata.inputSchema, 'sourceVideoDurationSeconds') ) { input.sourceVideoDurationSeconds = await loadVideoDurationSecondsImpl( - referencePayload.videoUrls[0], + draftCard.referenceCardIds + .map((cardId) => canvasCards[cardId]) + .find((card) => card?.type === 'video' && card.url)?.url ?? + referencePayload.videoUrls[0], runtimeMessages ); } @@ -489,7 +503,8 @@ type RunDraftGenerationParams = { projectId?: string; getCurrentCard: (draftId: string) => CanvasCard | null | undefined; buildEffectInput: ( - draftCard: CanvasDraftCard + draftCard: CanvasDraftCard, + referenceUrlOverrides?: Record ) => Promise; getExpectedUploadCount?: (draftCard: CanvasDraftCard) => number; updateDraftCard: (draftId: string, patch: Partial) => void; @@ -521,7 +536,7 @@ type RunDraftGenerationParams = { precheckEffectImpl?: typeof defaultPrecheckEffect; prepareAfterPrecheck?: (precheck: { uploadIntentToken?: string; - }) => Promise; + }) => Promise | void>; generateEffectImpl?: typeof defaultGenerateEffect; pollEffectUntilCompleteImpl: (params: { wmTaskId: string; @@ -611,9 +626,13 @@ export const runDraftGeneration = async ({ ); } + let referenceUrlOverrides: Record = {}; if (prepareAfterPrecheck) { try { - await prepareAfterPrecheck({ uploadIntentToken: generationIntentToken }); + referenceUrlOverrides = + (await prepareAfterPrecheck({ + uploadIntentToken: generationIntentToken, + })) ?? {}; } catch (error) { throw new GenerationFailure( 'storage', @@ -629,7 +648,10 @@ export const runDraftGeneration = async ({ translate('messages.generationFailed') ); } - const preparedRequest = await buildEffectInput(preparedCard); + const preparedRequest = await buildEffectInput( + preparedCard, + referenceUrlOverrides + ); if (preparedRequest.effectId !== effectId) { throw new GenerationFailure( 'precheck', @@ -641,7 +663,9 @@ export const runDraftGeneration = async ({ const card = getCurrentCard(cardId); return ( typeof card?.url === 'string' && - isLocalWorkspaceMediaUrl(card.url) + isLocalWorkspaceMediaUrl(card.url) && + (!referenceUrlOverrides[cardId] || + isLocalWorkspaceMediaUrl(referenceUrlOverrides[cardId])) ); } ); diff --git a/src/core/effects/output-storage.test.ts b/src/core/effects/output-storage.test.ts index 033d78a..4385741 100644 --- a/src/core/effects/output-storage.test.ts +++ b/src/core/effects/output-storage.test.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { shouldRetryOutputStorageSync } from './output-storage'; +import { + buildOutputStoragePlan, + shouldRetryOutputStorageSync, +} from './output-storage'; test('provider-owned output URLs do not require a second storage sync', () => { assert.equal( @@ -26,3 +29,27 @@ test('provider-owned output URLs do not require a second storage sync', () => { false ); }); + +test('stores video covers as thumbnails without replacing the main output asset', () => { + assert.deepEqual( + buildOutputStoragePlan({ + effectType: 1, + output: { + video_url: 'https://media.beatapi.io/outputs/task-1/result.mp4', + cover_url: 'https://media.beatapi.io/outputs/task-1/cover.jpg', + }, + }), + [ + { + url: 'https://media.beatapi.io/outputs/task-1/result.mp4', + type: 'video', + role: 'output', + }, + { + url: 'https://media.beatapi.io/outputs/task-1/cover.jpg', + type: 'image', + role: 'thumbnail', + }, + ] + ); +}); diff --git a/src/core/effects/output-storage.ts b/src/core/effects/output-storage.ts index 5b80278..0d06d16 100644 --- a/src/core/effects/output-storage.ts +++ b/src/core/effects/output-storage.ts @@ -1,18 +1,113 @@ -import { createHash } from 'crypto'; +import { createHash } from 'node:crypto'; import { resolveOutputMedia } from './output-media'; +import { isOfficialBeatApiMediaUrl } from './beatapi-media-url'; +import { getGenerationById } from './record-generation'; +import { + LOCAL_PROJECT_ASSET_BUCKET, + LOCAL_PROJECT_ASSET_PROVIDER, + persistLocalProjectAsset, +} from '@/core/projects/local-project-assets'; import { linkGenerationAsset, recordUserAsset, type AssetType, } from '@/core/workspace-lib/assets/user-assets'; +import { readResponseBodyWithLimit } from '@/lib/response-body-limit'; export const OUTPUT_STORAGE_SYNC_RETRY_ERROR = ''; export const didOutputStorageSyncFail = (_output?: unknown) => false; export const shouldRetryOutputStorageSync = (_input?: unknown) => false; -const objectKeyForUrl = (url: string) => - `provider/${createHash('sha256').update(url).digest('hex')}`; +export const MAX_LOCAL_IMAGE_ASSET_BYTES = 25 * 1024 * 1024; +export const MAX_LOCAL_VIDEO_ASSET_BYTES = 100 * 1024 * 1024; +export const LOCAL_MEDIA_DOWNLOAD_TIMEOUT_MS = 180_000; + +const isLocalProjectAssetUrl = (url: string) => + url.startsWith('/api/app/projects/') && url.includes('/assets/'); + +export const createLocalProviderAssetId = ( + projectId: string, + providerUrl: string +) => + createHash('sha256') + .update(`${projectId}\0${providerUrl}`) + .digest('hex'); + +const filenameFromUrl = (url: string, type: AssetType) => { + try { + const filename = decodeURIComponent( + new URL(url).pathname.split('/').pop() || '' + ); + if (filename) return filename; + } catch { + // Fall through to a stable local filename. + } + return type === 'video' ? 'generated-video.mp4' : 'generated-image.png'; +}; + +const replaceMediaUrls = ( + value: unknown, + localUrlByProviderUrl: Map +): unknown => { + if (typeof value === 'string') { + return localUrlByProviderUrl.get(value) ?? value; + } + if (Array.isArray(value)) { + return value.map((item) => replaceMediaUrls(item, localUrlByProviderUrl)); + } + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + replaceMediaUrls(item, localUrlByProviderUrl), + ]) + ); +}; + +type OutputStoragePlanEntry = { + url: string; + type: AssetType; + role: 'output' | 'thumbnail'; +}; + +export const buildOutputStoragePlan = ({ + output, + effectType, +}: { + output: unknown; + effectType: number; +}): OutputStoragePlanEntry[] => { + if (effectType === 3) return []; + const media = resolveOutputMedia(output); + const mediaEntries = new Map< + string, + Omit + >(); + const appendMedia = ( + type: AssetType, + role: OutputStoragePlanEntry['role'], + values: Array + ) => { + for (const url of values) { + if (url && !mediaEntries.has(url)) mediaEntries.set(url, { type, role }); + } + }; + if (effectType === 1) { + appendMedia('video', 'output', [...media.videoUrls, media.resultUrl]); + appendMedia('image', 'thumbnail', [...media.coverUrls, media.coverUrl]); + } else { + appendMedia( + 'image', + 'output', + [...media.imageUrls, ...media.resultUrls, media.resultUrl] + ); + } + return [...mediaEntries.entries()].map(([url, entry]) => ({ + url, + ...entry, + })); +}; export async function persistEffectOutputIfNeeded({ output, @@ -25,37 +120,97 @@ export async function persistEffectOutputIfNeeded({ effectId: number; effectType: number; }) { - if (effectType === 3) return output; + const storagePlan = buildOutputStoragePlan({ output, effectType }); + if (storagePlan.length === 0) return output; const media = resolveOutputMedia(output); - const urls = Array.from( - new Set( - effectType === 1 - ? [...media.videoUrls, media.resultUrl] - : [...media.imageUrls, ...media.resultUrls, media.resultUrl] - ) - ).filter((url): url is string => Boolean(url)); - const type: AssetType = effectType === 1 ? 'video' : 'image'; + const providerEntries = storagePlan.filter( + ({ url }) => !isLocalProjectAssetUrl(url) + ); + if (providerEntries.length === 0) return output; + + const generation = await getGenerationById({ id: wmTaskId, effectId }); + if (!generation?.projectId) { + throw new Error('Generated media cannot be saved without a project'); + } + const assetIds: string[] = []; + const localUrlByProviderUrl = new Map(); - for (const url of urls) { + for (const { url, type, role } of providerEntries) { + if (!isOfficialBeatApiMediaUrl(url)) { + throw new Error('Generated media URL is not an approved BeatAPI asset'); + } + const response = await fetch(url, { + signal: AbortSignal.timeout(LOCAL_MEDIA_DOWNLOAD_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`Failed to save generated media locally (${response.status})`); + } + + const maxBytes = + type === 'video' + ? MAX_LOCAL_VIDEO_ASSET_BYTES + : MAX_LOCAL_IMAGE_ASSET_BYTES; + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error('Generated media is too large to save locally'); + } + const bytes = await readResponseBodyWithLimit(response, maxBytes); + const fallbackMimeType = type === 'video' ? 'video/mp4' : 'image/png'; + const responseMimeType = + response.headers.get('content-type')?.split(';')[0]?.trim() || ''; + const mimeType = responseMimeType.startsWith(`${type}/`) + ? responseMimeType + : fallbackMimeType; + const persisted = await persistLocalProjectAsset({ + projectId: generation.projectId, + assetId: createLocalProviderAssetId(generation.projectId, url), + filename: filenameFromUrl(url, type), + mimeType, + bytes, + }); const assetId = await recordUserAsset({ + id: persisted.assetId, type, source: 'provider', - storageProvider: 'beatapi', - bucket: 'beatapi', - objectKey: objectKeyForUrl(url), - publicUrl: url, - metadata: { effectId, generationId: wmTaskId }, + storageProvider: LOCAL_PROJECT_ASSET_PROVIDER, + bucket: LOCAL_PROJECT_ASSET_BUCKET, + objectKey: persisted.objectKey, + publicUrl: persisted.publicUrl, + filename: persisted.filename, + mimeType, + sizeBytes: persisted.sizeBytes, + sha256: persisted.sha256, + originProjectId: generation.projectId, + metadata: { + effectId, + generationId: wmTaskId, + providerUrl: url, + }, }); - await linkGenerationAsset({ generationId: wmTaskId, assetId, role: 'output' }); + await linkGenerationAsset({ generationId: wmTaskId, assetId, role }); assetIds.push(assetId); + localUrlByProviderUrl.set(url, persisted.publicUrl); } if (!output || typeof output !== 'object' || assetIds.length === 0) { return output; } + const localizedOutput = replaceMediaUrls( + output, + localUrlByProviderUrl + ) as Record; + const providerResultUrl = media.resultUrl; return { - ...(output as Record), + ...localizedOutput, + ...(providerResultUrl + ? { + provider_result_url: providerResultUrl, + stored_result_url: + localUrlByProviderUrl.get(providerResultUrl) ?? providerResultUrl, + } + : {}), + provider_result_urls: providerEntries.map(({ url }) => url), assetIds, storage_sync_failed: false, };