import type { ElectronAPI } from '../../shared/electron-api-schema' import type { Asset } from '../types/project-model' import { logger } from './logger' interface VisualAssetMetadataMigrationJob { path: string type: 'video' | 'image' needsThumbnails: boolean needsDimensions: boolean } interface ThumbnailPaths { bigThumbnailPath: string smallThumbnailPath: string } interface VisualAssetDimensions { width: number height: number } interface VisualAssetMigrationResult extends Partial, Partial {} export interface VisualAssetMetadataMigrationUpdate { assetId: string updates: Partial } export type VisualAssetMetadataMigrationEvent = | { kind: 'progress' total: number completed: number } | { kind: 'complete' total: number completed: number updates: VisualAssetMetadataMigrationUpdate[] } function isVisualAsset(asset: Asset): asset is Asset & { type: 'video' | 'image' } { return asset.type === 'video' || asset.type === 'image' } function isMissingThumbnailPair(item: { bigThumbnailPath?: string; smallThumbnailPath?: string }): boolean { return !item.bigThumbnailPath || !item.smallThumbnailPath } function isMissingDimensions(item: { width?: number; height?: number }): boolean { return !item.width || !item.height } function collectVisualAssetMetadataMigrationJobs(assets: Asset[]): VisualAssetMetadataMigrationJob[] { const jobs = new Map() for (const asset of assets) { if (!isVisualAsset(asset)) continue if (asset.path && (isMissingThumbnailPair(asset) || isMissingDimensions(asset))) { const existingJob = jobs.get(asset.path) jobs.set(asset.path, { path: asset.path, type: asset.type, needsThumbnails: (existingJob?.needsThumbnails || false) || isMissingThumbnailPair(asset), needsDimensions: (existingJob?.needsDimensions || false) || isMissingDimensions(asset), }) } for (const take of asset.takes ?? []) { if (take.path && (isMissingThumbnailPair(take) || isMissingDimensions(take))) { const existingJob = jobs.get(take.path) jobs.set(take.path, { path: take.path, type: asset.type, needsThumbnails: (existingJob?.needsThumbnails || false) || isMissingThumbnailPair(take), needsDimensions: (existingJob?.needsDimensions || false) || isMissingDimensions(take), }) } } } return Array.from(jobs.values()) } function buildVisualAssetMetadataMigrationPatch( asset: Asset, migrationResults: Map, ): Partial | null { if (!isVisualAsset(asset)) { return null } const updates: Partial = {} const assetMetadata = migrationResults.get(asset.path) if (assetMetadata?.bigThumbnailPath && asset.bigThumbnailPath !== assetMetadata.bigThumbnailPath) { updates.bigThumbnailPath = assetMetadata.bigThumbnailPath } if (assetMetadata?.smallThumbnailPath && asset.smallThumbnailPath !== assetMetadata.smallThumbnailPath) { updates.smallThumbnailPath = assetMetadata.smallThumbnailPath } if (assetMetadata?.width && asset.width !== assetMetadata.width) { updates.width = assetMetadata.width } if (assetMetadata?.height && asset.height !== assetMetadata.height) { updates.height = assetMetadata.height } if (asset.takes && asset.takes.length > 0) { let takesChanged = false const nextTakes = asset.takes.map(take => { const takeMetadata = migrationResults.get(take.path) if (!takeMetadata) { return take } if ( (takeMetadata.bigThumbnailPath === undefined || take.bigThumbnailPath === takeMetadata.bigThumbnailPath) && (takeMetadata.smallThumbnailPath === undefined || take.smallThumbnailPath === takeMetadata.smallThumbnailPath) && (takeMetadata.width === undefined || take.width === takeMetadata.width) && (takeMetadata.height === undefined || take.height === takeMetadata.height) ) { return take } takesChanged = true return { ...take, ...(takeMetadata.bigThumbnailPath ? { bigThumbnailPath: takeMetadata.bigThumbnailPath } : {}), ...(takeMetadata.smallThumbnailPath ? { smallThumbnailPath: takeMetadata.smallThumbnailPath } : {}), ...(takeMetadata.width ? { width: takeMetadata.width } : {}), ...(takeMetadata.height ? { height: takeMetadata.height } : {}), } }) if (takesChanged) { updates.takes = nextTakes } } return Object.keys(updates).length > 0 ? updates : null } export function hasVisualAssetMetadataForMigration(assets: Asset[]): boolean { return assets.some(asset => { if (!isVisualAsset(asset)) return false if (isMissingThumbnailPair(asset) || isMissingDimensions(asset)) return true return (asset.takes ?? []).some(take => isMissingThumbnailPair(take) || isMissingDimensions(take)) }) } export async function* runVisualAssetMetadataMigration( assets: Asset[], electronAPI: Pick, ): AsyncGenerator { const jobs = collectVisualAssetMetadataMigrationJobs(assets) if (jobs.length === 0) { yield { kind: 'complete', total: 0, completed: 0, updates: [], } return } yield { kind: 'progress', total: jobs.length, completed: 0, } const migrationResults = new Map() let completed = 0 for (const job of jobs) { try { const nextResult: VisualAssetMigrationResult = {} if (job.needsThumbnails) { const thumbnailResult = await electronAPI.makeThumbnailsForProjectAsset({ path: job.path, type: job.type, }) if (thumbnailResult.success) { nextResult.bigThumbnailPath = thumbnailResult.bigThumbnailPath nextResult.smallThumbnailPath = thumbnailResult.smallThumbnailPath } else { logger.warn(`Thumbnail migration skipped for ${job.path}: ${thumbnailResult.error}`) } } if (job.needsDimensions) { const dimensionsResult = await electronAPI.makeDimensionsForProjectAsset({ path: job.path, type: job.type, }) if (dimensionsResult.success) { nextResult.width = dimensionsResult.width nextResult.height = dimensionsResult.height } else { logger.warn(`Dimensions migration skipped for ${job.path}: ${dimensionsResult.error}`) } } if (Object.keys(nextResult).length > 0) { migrationResults.set(job.path, nextResult) } } catch (error) { logger.warn(`Asset metadata migration skipped for ${job.path}: ${error}`) } completed += 1 yield { kind: 'progress', total: jobs.length, completed, } } const updates: VisualAssetMetadataMigrationUpdate[] = [] for (const asset of assets) { const assetUpdates = buildVisualAssetMetadataMigrationPatch(asset, migrationResults) if (assetUpdates) { updates.push({ assetId: asset.id, updates: assetUpdates, }) } } yield { kind: 'complete', total: jobs.length, completed, updates, } }