import { existsSync } from 'node:fs'; import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; import { basename, extname, join } from 'node:path'; import { createHash } from 'node:crypto'; import { VclawError } from '../errors.js'; import { artifactPathFor, writeArtifact } from '../artifact-store.js'; import { writeTextFileAtomic } from '../atomic-write.js'; import type { AssetManifestArtifact } from '../artifacts.js'; import type { VideoProjectWorkspace } from '../workspace.js'; import type { StockFetch, StockFetchResponse, StockImportReceipt, StockRendition, StockSearchResult } from './types.js'; const DEFAULT_TIMEOUT_MS = 20_000; const DEFAULT_MAX_DOWNLOAD_BYTES = 250 * 1024 * 1024; const PEXELS_PAGE_HOSTS = new Set(['pexels.com', 'www.pexels.com']); const PEXELS_DOWNLOAD_HOSTS = new Set(['videos.pexels.com', 'player.vimeo.com']); function sha256Buffer(buffer: Buffer): string { return `sha256:${createHash('sha256').update(buffer).digest('hex')}`; } function sha256Text(value: string): string { return `sha256:${createHash('sha256').update(value).digest('hex')}`; } function stableJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } function safeSegment(value: string): string { return value.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'asset'; } function renditionExtension(rendition: StockRendition): string { const fromMime = rendition.mimeType === 'video/mp4' ? '.mp4' : ''; if (fromMime) return fromMime; const fromUrl = extname(new URL(rendition.url).pathname); return fromUrl && fromUrl.length <= 8 ? fromUrl : '.mp4'; } function isVideoMime(mimeType: string): boolean { return mimeType.toLowerCase().startsWith('video/'); } async function writeBinaryFileAtomic(path: string, content: Buffer): Promise { const tempPath = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; try { await writeFile(tempPath, content); await rename(tempPath, path); } catch (error) { await unlink(tempPath).catch(() => {}); throw error; } } async function readManifest(workspace: VideoProjectWorkspace): Promise { const path = artifactPathFor(workspace, 'asset-manifest'); if (!existsSync(path)) return { projectSlug: workspace.slug, assets: [] }; return JSON.parse(await readFile(path, 'utf-8')) as AssetManifestArtifact; } async function downloadRendition(input: { fetchImpl: StockFetch; rendition: StockRendition; timeoutMs: number; maxDownloadBytes: number; }): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), input.timeoutMs); try { const response = await input.fetchImpl(input.rendition.url, { method: 'GET', signal: controller.signal, redirect: 'manual' }); if (response.status >= 300 && response.status < 400) { throw new VclawError('stock_media_invalid', 'Stock downloads must not redirect.', { provider: 'pexels', url: input.rendition.url, status: response.status, }); } if (!response.ok) { throw new VclawError('stock_download_failed', `Stock download failed with HTTP ${response.status}.`, { status: response.status }); } const headerMime = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase() ?? ''; if (headerMime && !isVideoMime(headerMime)) { throw new VclawError('stock_media_invalid', `Stock download returned non-video MIME type: ${headerMime}.`, { mimeType: headerMime }); } const lengthHeader = response.headers.get('content-length'); const declaredBytes = lengthHeader ? Number(lengthHeader) : undefined; if (declaredBytes !== undefined && Number.isFinite(declaredBytes) && declaredBytes > input.maxDownloadBytes) { throw new VclawError('stock_media_invalid', `Stock download exceeds ${input.maxDownloadBytes} bytes.`, { sizeBytes: declaredBytes }); } const buffer = response.body ? await readBoundedBody(response.body, input.maxDownloadBytes, 'Stock download') : Buffer.from(await response.arrayBuffer()); if (buffer.byteLength > input.maxDownloadBytes) { throw new VclawError('stock_media_invalid', `Stock download exceeds ${input.maxDownloadBytes} bytes.`, { sizeBytes: buffer.byteLength }); } return buffer; } catch (error) { if ((error instanceof Error && error.name === 'AbortError') || controller.signal.aborted) { throw new VclawError('stock_download_failed', 'Stock download timed out.', { reason: 'timeout', timeoutMs: input.timeoutMs }); } throw error; } finally { clearTimeout(timer); } } async function readBoundedBody(body: NonNullable, maxBytes: number, label: string): Promise { const chunks: Buffer[] = []; let total = 0; const append = (value: Uint8Array): void => { const chunk = Buffer.from(value); total += chunk.byteLength; if (total > maxBytes) { throw new VclawError('stock_media_invalid', `${label} exceeds ${maxBytes} bytes.`, { sizeBytes: total, maxDownloadBytes: maxBytes, }); } chunks.push(chunk); }; if (Symbol.asyncIterator in Object(body)) { for await (const chunk of body as AsyncIterable) append(chunk); } else { const reader = (body as ReadableStream).getReader(); try { for (;;) { const next = await reader.read(); if (next.done) break; append(next.value); } } finally { reader.releaseLock(); } } return Buffer.concat(chunks); } function assertHttpsHost(raw: string, allowedHosts: ReadonlySet, label: string): URL { let url: URL; try { url = new URL(raw); } catch { throw new VclawError('stock_media_invalid', `${label} is not a valid URL.`, { value: raw }); } if (url.protocol !== 'https:' || !allowedHosts.has(url.hostname.toLowerCase())) { throw new VclawError('stock_media_invalid', `${label} must use HTTPS and an allowed host.`, { host: url.hostname, allowedHosts: [...allowedHosts], }); } return url; } function nonEmpty(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } function assertPositiveNumber(value: unknown, label: string): number { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { throw new VclawError('stock_media_invalid', `${label} must be a positive number.`, { value }); } return value; } function validateSelection(selection: StockSearchResult): void { if (selection.provider !== 'pexels') { throw new VclawError('stock_provider_unavailable', `Unsupported stock provider: ${selection.provider}.`, { provider: selection.provider }); } if (!nonEmpty(selection.providerAssetId)) throw new VclawError('stock_media_invalid', 'Stock selection missing providerAssetId.'); assertHttpsHost(selection.sourcePageUrl, PEXELS_PAGE_HOSTS, 'Stock source page URL'); if (!selection.creator || !nonEmpty(selection.creator.name)) { throw new VclawError('stock_media_invalid', 'Stock selection missing creator attribution.'); } if (selection.creator.url) assertHttpsHost(selection.creator.url, PEXELS_PAGE_HOSTS, 'Stock creator URL'); validateLicense(selection.license); if (!Array.isArray(selection.renditions) || selection.renditions.length === 0) { throw new VclawError('stock_media_invalid', 'Stock selection has no renditions.'); } for (const rendition of selection.renditions) validateRendition(rendition); } function validateLicense(license: StockSearchResult['license']): void { if (!license || license.name !== 'Pexels License' || license.url !== 'https://www.pexels.com/license/') { throw new VclawError('stock_license_missing', 'Selected stock asset is missing a recognized Pexels License.', { expectedName: 'Pexels License', expectedUrl: 'https://www.pexels.com/license/', }); } assertHttpsHost(license.url, PEXELS_PAGE_HOSTS, 'Stock license URL'); if (!Array.isArray(license.usageScope) || license.usageScope.length === 0) { throw new VclawError('stock_license_missing', 'Selected stock license is missing usageScope.'); } if (!Array.isArray(license.termsRestrictions) || license.termsRestrictions.length === 0) { throw new VclawError('stock_license_missing', 'Selected stock license is missing termsRestrictions.'); } } function validateRendition(rendition: StockRendition): void { if (!nonEmpty(rendition.id)) throw new VclawError('stock_media_invalid', 'Stock rendition missing id.'); if (!isVideoMime(rendition.mimeType)) { throw new VclawError('stock_media_invalid', `Selected stock rendition is not video: ${rendition.mimeType}.`, { mimeType: rendition.mimeType }); } assertPositiveNumber(rendition.width, 'Stock rendition width'); assertPositiveNumber(rendition.height, 'Stock rendition height'); assertPositiveNumber(rendition.durationSeconds, 'Stock rendition durationSeconds'); assertHttpsHost(rendition.url, PEXELS_DOWNLOAD_HOSTS, 'Stock rendition download URL'); } async function validateReceipt(receipt: StockImportReceipt, workspace: VideoProjectWorkspace): Promise { if (receipt.schemaVersion !== 1) throw new VclawError('stock_media_invalid', 'Stock import receipt has unsupported schemaVersion.'); if (!nonEmpty(receipt.receiptId)) throw new VclawError('stock_media_invalid', 'Stock import receipt missing receiptId.'); if (!nonEmpty(receipt.importedAt) || !Number.isFinite(Date.parse(receipt.importedAt))) { throw new VclawError('stock_media_invalid', 'Stock import receipt importedAt is invalid.'); } if (receipt.projectSlug !== workspace.slug) throw new VclawError('stock_media_invalid', 'Stock import receipt projectSlug does not match workspace.'); if (receipt.provider !== 'pexels') throw new VclawError('stock_provider_unavailable', `Unsupported stock receipt provider: ${receipt.provider}.`); if (!nonEmpty(receipt.providerAssetId) || !nonEmpty(receipt.providerRenditionId)) { throw new VclawError('stock_media_invalid', 'Stock import receipt missing provider asset/rendition ids.'); } assertHttpsHost(receipt.sourcePageUrl, PEXELS_PAGE_HOSTS, 'Stock receipt source page URL'); if (!receipt.creator || !nonEmpty(receipt.creator.name)) throw new VclawError('stock_media_invalid', 'Stock import receipt missing creator attribution.'); if (receipt.creator.url) assertHttpsHost(receipt.creator.url, PEXELS_PAGE_HOSTS, 'Stock receipt creator URL'); validateLicense(receipt.license); if (!nonEmpty(receipt.searchQuery)) throw new VclawError('stock_media_invalid', 'Stock import receipt missing searchQuery.'); assertHttpsHost(receipt.downloadedUrl, PEXELS_DOWNLOAD_HOSTS, 'Stock receipt downloaded URL'); if (!nonEmpty(receipt.localAssetPath) || !receipt.localAssetPath.startsWith(`assets/stock/${receipt.provider}/`)) { throw new VclawError('stock_media_invalid', 'Stock import receipt localAssetPath is invalid.', { localAssetPath: receipt.localAssetPath }); } if (!/^sha256:[a-f0-9]{64}$/.test(receipt.contentHash)) throw new VclawError('stock_media_invalid', 'Stock import receipt contentHash is invalid.'); assertPositiveNumber(receipt.sizeBytes, 'Stock receipt sizeBytes'); assertPositiveNumber(receipt.width, 'Stock receipt width'); assertPositiveNumber(receipt.height, 'Stock receipt height'); assertPositiveNumber(receipt.durationSeconds, 'Stock receipt durationSeconds'); if (!isVideoMime(receipt.mimeType)) throw new VclawError('stock_media_invalid', 'Stock import receipt MIME type is not video.'); if (!Array.isArray(receipt.usageScope) || receipt.usageScope.length === 0 || !Array.isArray(receipt.termsRestrictions) || receipt.termsRestrictions.length === 0) { throw new VclawError('stock_license_missing', 'Stock import receipt is missing rights scope or restrictions.'); } if (JSON.stringify(receipt.usageScope) !== JSON.stringify(receipt.license.usageScope) || JSON.stringify(receipt.termsRestrictions) !== JSON.stringify(receipt.license.termsRestrictions)) { throw new VclawError('stock_license_missing', 'Stock import receipt rights fields do not match its license record.'); } const assetPath = join(workspace.projectDir, receipt.localAssetPath); if (!existsSync(assetPath)) throw new VclawError('stock_media_invalid', 'Stock import receipt points to a missing local asset.', { localAssetPath: receipt.localAssetPath }); const assetBytes = await readFile(assetPath); const actualHash = sha256Buffer(assetBytes); if (actualHash !== receipt.contentHash) { throw new VclawError('stock_media_invalid', 'Stock import receipt contentHash does not match local asset bytes.', { expected: receipt.contentHash, actual: actualHash, }); } } function assertReceiptMatchesSelection( receipt: StockImportReceipt, selection: StockSearchResult, rendition: StockRendition, receiptId: string, ): void { const matches = receipt.receiptId === receiptId && receipt.provider === selection.provider && receipt.providerAssetId === selection.providerAssetId && receipt.providerRenditionId === rendition.id && receipt.sourcePageUrl === selection.sourcePageUrl && receipt.downloadedUrl === rendition.url; if (!matches) { throw new VclawError('stock_media_invalid', 'Existing stock import receipt does not match the selected asset and rendition.', { receiptId, providerAssetId: selection.providerAssetId, providerRenditionId: rendition.id, }); } } export function stockReceiptId(result: StockSearchResult, rendition: StockRendition): string { return safeSegment(`${result.provider}-${result.providerAssetId}-${rendition.id}`); } export async function importStockRendition(input: { workspace: VideoProjectWorkspace; selection: StockSearchResult; renditionId: string; sceneIndex?: number; fetch: StockFetch; timeoutMs?: number; maxDownloadBytes?: number; importedAt?: string; }): Promise<{ receipt: StockImportReceipt; receiptPath: string; manifestPath: string; assetPath: string; manifest: AssetManifestArtifact; reused: boolean }> { validateSelection(input.selection); const rendition = input.selection.renditions.find((candidate) => candidate.id === input.renditionId); if (!rendition) { throw new VclawError('stock_media_invalid', `Selected stock rendition not found: ${input.renditionId}.`, { renditionId: input.renditionId }); } validateRendition(rendition); const receiptId = stockReceiptId(input.selection, rendition); const receiptsDir = join(input.workspace.artifactsDir, 'stock-imports'); const receiptPath = join(receiptsDir, `${receiptId}.json`); const assetId = `stock-${receiptId}`; if (existsSync(receiptPath)) { const receipt = JSON.parse(await readFile(receiptPath, 'utf-8')) as StockImportReceipt; await validateReceipt(receipt, input.workspace); assertReceiptMatchesSelection(receipt, input.selection, rendition, receiptId); const manifest = await ensureManifestEntry(input.workspace, receipt, assetId, input.sceneIndex, receiptPath); return { receipt, receiptPath, manifestPath: artifactPathFor(input.workspace, 'asset-manifest'), assetPath: join(input.workspace.projectDir, receipt.localAssetPath), manifest, reused: true, }; } const buffer = await downloadRendition({ fetchImpl: input.fetch, rendition, timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, maxDownloadBytes: input.maxDownloadBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES, }); const contentHash = sha256Buffer(buffer); const hashHex = contentHash.slice('sha256:'.length); const assetsDir = join(input.workspace.projectDir, 'assets', 'stock', input.selection.provider); await mkdir(assetsDir, { recursive: true }); const assetFile = `${hashHex}${renditionExtension(rendition)}`; const assetPath = join(assetsDir, assetFile); if (!existsSync(assetPath)) { await writeBinaryFileAtomic(assetPath, buffer); } const localAssetPath = join('assets', 'stock', input.selection.provider, basename(assetPath)); const importedAt = input.importedAt ?? new Date().toISOString(); const receipt: StockImportReceipt = { schemaVersion: 1, receiptId, importedAt, projectSlug: input.workspace.slug, provider: input.selection.provider, providerAssetId: input.selection.providerAssetId, providerRenditionId: rendition.id, sourcePageUrl: input.selection.sourcePageUrl, creator: input.selection.creator, license: input.selection.license, searchQuery: input.selection.searchQuery, downloadedUrl: rendition.url, localAssetPath, contentHash, sizeBytes: (await stat(assetPath)).size, mimeType: rendition.mimeType, width: rendition.width, height: rendition.height, durationSeconds: rendition.durationSeconds, usageScope: input.selection.license.usageScope, termsRestrictions: input.selection.license.termsRestrictions, }; await validateReceiptBeforeWrite(receipt, input.workspace); await mkdir(receiptsDir, { recursive: true }); await writeTextFileAtomic(receiptPath, stableJson(receipt)); const manifest = await ensureManifestEntry(input.workspace, receipt, assetId, input.sceneIndex, receiptPath); return { receipt, receiptPath, manifestPath: artifactPathFor(input.workspace, 'asset-manifest'), assetPath, manifest, reused: false, }; } async function validateReceiptBeforeWrite(receipt: StockImportReceipt, workspace: VideoProjectWorkspace): Promise { const assetPath = join(workspace.projectDir, receipt.localAssetPath); if (!existsSync(assetPath)) throw new VclawError('stock_media_invalid', 'Stock import asset was not written before receipt validation.'); await validateReceipt(receipt, workspace); } async function ensureManifestEntry( workspace: VideoProjectWorkspace, receipt: StockImportReceipt, assetId: string, sceneIndex: number | undefined, receiptPath: string, ): Promise { const manifest = await readManifest(workspace); const receiptHash = sha256Text(await readFile(receiptPath, 'utf-8')); const exists = manifest.assets.some((asset) => asset.id === assetId || (asset.provenance?.receipt?.id === receipt.receiptId) || (asset.provenance?.sourceProvider === receipt.provider && asset.provenance.sourceAssetId === receipt.providerAssetId && asset.provenance.sourceRenditionId === receipt.providerRenditionId) ); if (!exists) { manifest.assets.push({ id: assetId, kind: 'video', path: receipt.localAssetPath, ...(sceneIndex !== undefined ? { sceneIndex } : {}), sourceProvider: receipt.provider, provenance: { sourceProvider: receipt.provider, sourceAssetId: receipt.providerAssetId, sourceRenditionId: receipt.providerRenditionId, sourcePageUrl: receipt.sourcePageUrl, creator: receipt.creator, license: receipt.license, searchQuery: receipt.searchQuery, downloadedAt: receipt.importedAt, contentHash: receipt.contentHash, usageScope: receipt.usageScope, termsRestrictions: receipt.termsRestrictions, receipt: { id: receipt.receiptId, path: `artifacts/stock-imports/${receipt.receiptId}.json`, sha256: receiptHash, }, }, }); await writeArtifact(workspace, 'asset-manifest', manifest); } return manifest; }