From 7de75f7b2bc2f442e2052b0ebd707638fbad011b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Wed, 19 Nov 2025 10:30:20 +0100 Subject: [PATCH] refactor: export/import projects as ZIP files instead of JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/editor/AudioEditor.tsx | 2 +- lib/storage/projects.ts | 132 ++++++++++++++++++++++-------- package.json | 1 + pnpm-lock.yaml | 85 +++++++++++++++++++ 4 files changed, 185 insertions(+), 35 deletions(-) diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index 44aa9ba..ee195fc 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -1143,7 +1143,7 @@ export function AudioEditor() { // Create file input const input = document.createElement('input'); input.type = 'file'; - input.accept = '.json'; + input.accept = '.zip'; input.onchange = async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; diff --git a/lib/storage/projects.ts b/lib/storage/projects.ts index d79aad2..f7a844e 100644 --- a/lib/storage/projects.ts +++ b/lib/storage/projects.ts @@ -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 { + 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 { } /** - * Import project from JSON file + * Import project from ZIP file */ export async function importProjectFromJSON(file: File): Promise { - 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'); + } } diff --git a/package.json b/package.json index 15fa1ae..a653acf 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "clsx": "^2.1.1", "fflate": "^0.8.2", + "jszip": "^3.10.1", "lamejs": "github:zhuker/lamejs", "lucide-react": "^0.553.0", "next": "^16.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf2fc72..8c292b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: fflate: specifier: ^0.8.2 version: 0.8.2 + jszip: + specifier: ^3.10.1 + version: 3.10.1 lamejs: specifier: github:zhuker/lamejs version: https://codeload.github.com/zhuker/lamejs/tar.gz/582bbba6a12f981b984d8fb9e1874499fed85675 @@ -831,6 +834,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1218,6 +1224,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1226,6 +1235,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1333,6 +1345,9 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -1381,6 +1396,9 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1399,6 +1417,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -1601,6 +1622,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1643,6 +1667,9 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1665,6 +1692,9 @@ packages: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -1700,6 +1730,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -1732,6 +1765,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1794,6 +1830,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -1907,6 +1946,9 @@ packages: use-strict@1.0.1: resolution: {integrity: sha512-IeiWvvEXfW5ltKVMkxq6FvNf2LojMKvB2OCeja6+ct24S1XOmQw2dGr2JyndwACWAGJva9B7yPHwAmeA9QCqAQ==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2707,6 +2749,8 @@ snapshots: convert-source-map@2.0.0: {} + core-util-is@1.0.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3242,6 +3286,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -3249,6 +3295,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3367,6 +3415,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -3409,6 +3459,13 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -3428,6 +3485,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.30.2: optional: true @@ -3616,6 +3677,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -3648,6 +3711,8 @@ snapshots: prelude-ls@1.2.1: {} + process-nextick-args@2.0.1: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3667,6 +3732,16 @@ snapshots: react@19.2.0: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -3717,6 +3792,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -3756,6 +3833,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -3881,6 +3960,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -4021,6 +4104,8 @@ snapshots: use-strict@1.0.1: {} + util-deprecate@1.0.2: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0