Files
LTX-DeskWEB/frontend/lib/project-asset-metadata-migration.ts
T

241 lines
7.2 KiB
TypeScript

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<ThumbnailPaths>, Partial<VisualAssetDimensions> {}
export interface VisualAssetMetadataMigrationUpdate {
assetId: string
updates: Partial<Asset>
}
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<string, VisualAssetMetadataMigrationJob>()
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<string, VisualAssetMigrationResult>,
): Partial<Asset> | null {
if (!isVisualAsset(asset)) {
return null
}
const updates: Partial<Asset> = {}
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<ElectronAPI, 'makeThumbnailsForProjectAsset' | 'makeDimensionsForProjectAsset'>,
): AsyncGenerator<VisualAssetMetadataMigrationEvent> {
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<string, VisualAssetMigrationResult>()
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,
}
}