export function readAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result as string) reader.onerror = () => reject(reader.error) reader.readAsDataURL(file) }) } export function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image() img.crossOrigin = 'anonymous' img.onload = () => resolve(img) img.onerror = () => reject(new Error(`failed to load image: ${src.slice(0, 60)}…`)) img.src = src }) } export interface Dimensions { width: number height: number } export function parseSvgDimensions(svgText: string): Dimensions | null { const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml') const svg = doc.documentElement if (!svg || svg.nodeName !== 'svg') return null const parseLen = (attr: string | null) => { if (!attr) return 0 const num = parseFloat(attr) return Number.isFinite(num) ? num : 0 } let w = parseLen(svg.getAttribute('width')) let h = parseLen(svg.getAttribute('height')) if (!w || !h) { const vb = svg.getAttribute('viewBox')?.trim().split(/[\s,]+/).map(Number) if (vb && vb.length === 4 && vb.every((n) => Number.isFinite(n))) { if (!w) w = vb[2] if (!h) h = vb[3] } } if (!w || !h) return null return { width: w, height: h } } export function isSvgFile(file: File): boolean { return file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg') } interface ImageImportStore { addAsset: (a: { kind: 'image' name: string src: string width: number height: number }) => { id: string } addLayer: (assetId: string, name?: string) => { id: string } | null upsertKeyframe?: (layerId: string, time: number, props: { x: number; y: number }) => void playhead?: number } export async function importImageFile( store: ImageImportStore, file: File, options?: { position?: { x: number; y: number } }, ): Promise<{ assetId: string; layerId: string } | null> { const dataUrl = await readAsDataUrl(file) let width = 0 let height = 0 if (isSvgFile(file)) { const text = await file.text() const dims = parseSvgDimensions(text) if (dims) { width = dims.width height = dims.height } } if (!width || !height) { const img = await loadImage(dataUrl) width = img.naturalWidth || 300 height = img.naturalHeight || 300 } const asset = store.addAsset({ kind: 'image', name: file.name || 'image', src: dataUrl, width, height, }) const layer = store.addLayer(asset.id) if (!layer) return null if (options?.position && store.upsertKeyframe) { store.upsertKeyframe(layer.id, store.playhead ?? 0, options.position) } return { assetId: asset.id, layerId: layer.id } } export function isAudioFile(file: File): boolean { if (file.type.startsWith('audio/')) return true const lower = file.name.toLowerCase() return lower.endsWith('.mp3') || lower.endsWith('.wav') || lower.endsWith('.ogg') || lower.endsWith('.m4a') } /** Decode an audio file far enough to learn its duration. Prefers an * OfflineAudioContext (deterministic, headless) and falls back to a * hidden `