refactor: export/import projects as ZIP files instead of JSON

Changed from single JSON file to ZIP archive format to avoid string
length limits when serializing large audio buffers.

New ZIP structure:
- project.json (metadata, track info, effects, automation)
- track_0.wav, track_1.wav, etc. (audio files in WAV format)

Benefits:
- No more RangeError: Invalid string length
- Smaller file sizes (WAV compression vs base64 JSON)
- Human-readable audio files in standard format
- Can extract and inspect individual tracks
- Easier to edit/debug project metadata

Added jszip dependency for ZIP file handling.
Changed file picker to accept .zip files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-19 10:30:20 +01:00
parent 543eb069d7
commit 7de75f7b2b
4 changed files with 185 additions and 35 deletions

View File

@@ -12,6 +12,7 @@ import {
deserializeAudioBuffer,
type ProjectData,
type SerializedTrack,
type SerializedAudioBuffer,
} from './db';
import type { ProjectMetadata } from './db';
import { getAudioContext } from '../audio/context';
@@ -212,21 +213,55 @@ export async function duplicateProject(sourceProjectId: string, newName: string)
}
/**
* Export project as JSON file
* Export project as ZIP file with separate audio files
*/
export async function exportProjectAsJSON(projectId: string): Promise<void> {
const JSZip = (await import('jszip')).default;
const project = await loadProject(projectId);
if (!project) throw new Error('Project not found');
// Convert the project to JSON
const json = JSON.stringify(project, null, 2);
const zip = new JSZip();
const audioContext = getAudioContext();
// Create blob and download
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// Create metadata without audio buffers
const metadata = {
...project,
tracks: project.tracks.map((track, index) => ({
...track,
audioBuffer: track.audioBuffer ? {
fileName: `track_${index}.wav`,
sampleRate: track.audioBuffer.sampleRate,
length: track.audioBuffer.length,
numberOfChannels: track.audioBuffer.numberOfChannels,
} : null,
})),
};
// Add project.json to ZIP
zip.file('project.json', JSON.stringify(metadata, null, 2));
// Convert audio buffers to WAV and add to ZIP
for (let i = 0; i < project.tracks.length; i++) {
const track = project.tracks[i];
if (track.audioBuffer) {
// Deserialize audio buffer
const buffer = deserializeAudioBuffer(track.audioBuffer, audioContext);
// Convert to WAV
const { audioBufferToWav } = await import('@/lib/audio/export');
const wavBlob = await audioBufferToWav(buffer);
// Add to ZIP
zip.file(`track_${i}.wav`, wavBlob);
}
}
// Generate ZIP and download
const zipBlob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = url;
a.download = `${project.metadata.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${Date.now()}.json`;
a.download = `${project.metadata.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${Date.now()}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -234,40 +269,69 @@ export async function exportProjectAsJSON(projectId: string): Promise<void> {
}
/**
* Import project from JSON file
* Import project from ZIP file
*/
export async function importProjectFromJSON(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
const JSZip = (await import('jszip')).default;
reader.onload = async (e) => {
try {
const json = e.target?.result as string;
const project = JSON.parse(json) as ProjectData;
try {
const zip = await JSZip.loadAsync(file);
// Generate new ID to avoid conflicts
const newId = generateProjectId();
const now = Date.now();
// Read project.json
const projectJsonFile = zip.file('project.json');
if (!projectJsonFile) throw new Error('Invalid project file: missing project.json');
const importedProject: ProjectData = {
...project,
metadata: {
...project.metadata,
id: newId,
name: `${project.metadata.name} (Imported)`,
createdAt: now,
updatedAt: now,
},
};
const projectJson = await projectJsonFile.async('text');
const metadata = JSON.parse(projectJson);
await saveProject(importedProject);
resolve(newId);
} catch (error) {
reject(new Error('Failed to parse project file'));
// Read audio files and reconstruct tracks
const audioContext = getAudioContext();
const tracks: SerializedTrack[] = [];
for (let i = 0; i < metadata.tracks.length; i++) {
const trackMeta = metadata.tracks[i];
let audioBuffer: SerializedAudioBuffer | null = null;
if (trackMeta.audioBuffer?.fileName) {
const audioFile = zip.file(trackMeta.audioBuffer.fileName);
if (audioFile) {
// Read WAV file as array buffer
const arrayBuffer = await audioFile.async('arraybuffer');
// Decode audio data
const decodedBuffer = await audioContext.decodeAudioData(arrayBuffer);
// Serialize for storage
audioBuffer = serializeAudioBuffer(decodedBuffer);
}
}
tracks.push({
...trackMeta,
audioBuffer,
});
}
// Generate new ID to avoid conflicts
const newId = generateProjectId();
const now = Date.now();
const importedProject: ProjectData = {
...metadata,
tracks,
metadata: {
...metadata.metadata,
id: newId,
name: `${metadata.metadata.name} (Imported)`,
createdAt: now,
updatedAt: now,
},
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
await saveProject(importedProject);
return newId;
} catch (error) {
console.error('Import error:', error);
throw new Error('Failed to import project file');
}
}