Files
RUWIO/src/lib/fileLoader.ts
T
2026-04-22 11:53:41 +02:00

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 }
}