Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
4.6 KiB
TypeScript
152 lines
4.6 KiB
TypeScript
export function readAsDataUrl(file: File): Promise<string> {
|
|
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<HTMLImageElement> {
|
|
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 `<audio>` element's `loadedmetadata` event if decoding is
|
|
* unsupported or fails (e.g. DRM-protected / exotic codecs). */
|
|
export function readAudioDuration(dataUrl: string): Promise<number> {
|
|
// Fallback path via HTMLAudioElement — lighter weight, works without
|
|
// Web Audio.
|
|
const viaMetadata = () =>
|
|
new Promise<number>((resolve, reject) => {
|
|
const audio = document.createElement('audio')
|
|
audio.preload = 'metadata'
|
|
audio.onloadedmetadata = () => {
|
|
const d = audio.duration
|
|
if (Number.isFinite(d) && d > 0) resolve(d)
|
|
else reject(new Error('audio duration unknown'))
|
|
}
|
|
audio.onerror = () => reject(new Error('audio metadata load failed'))
|
|
audio.src = dataUrl
|
|
})
|
|
return viaMetadata()
|
|
}
|
|
|
|
interface AudioImportStore {
|
|
addAudioAsset: (a: { name: string; src: string; duration: number }) => { id: string }
|
|
}
|
|
|
|
export async function importAudioFile(
|
|
store: AudioImportStore,
|
|
file: File,
|
|
): Promise<{ assetId: string } | null> {
|
|
const dataUrl = await readAsDataUrl(file)
|
|
let duration = 0
|
|
try {
|
|
duration = await readAudioDuration(dataUrl)
|
|
} catch {
|
|
// Unknown duration — store as 0. Trim logic clamps to the min visible,
|
|
// so the clip still renders; the user can re-drop the file if needed.
|
|
}
|
|
const name = file.name.replace(/\.[^.]+$/, '') || 'audio'
|
|
const asset = store.addAudioAsset({ name, src: dataUrl, duration })
|
|
return { assetId: asset.id }
|
|
}
|