{"schemaVersion":1,"id":"recommended.sm-export-timeline","version":"1.0.0","name":"Export timeline workflow","description":"An editable production workflow using Export timeline v2.0.0.","stages":[{"id":"main","blockId":"sm.export-timeline","version":"2.0.0","label":"Export timeline"}],"connections":[],"packages":[{"manifest":{"schemaVersion":1,"sdkVersion":"0.1.0","id":"sm.export-timeline","version":"2.0.0","name":"Export timeline","description":"Download your timeline as XML or a ZIP with selected media, preserving editing data and recorded audio notices.","kind":"editor-extension","runtime":"javascript","entry":"src/run.js","license":"MIT","provenance":{"notice":"Original source (authorship declared by the packager)\nLicense: MIT\nChanges: Original implementation declared by the packager; no reused repository or dependency code.\nFiles: LICENSE, src/run.js\n\nMIT License\n\nCopyright (c) 2026 StillMade contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and \nassociated documentation files (the \"Software\"), to deal in the Software without restriction, including \nwithout limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell \ncopies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the \nfollowing conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial \nportions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT \nLIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO \nEVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER \nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE \nUSE OR OTHER DEALINGS IN THE SOFTWARE.\n","portableSources":[{"kind":"original","license":"MIT","paths":["LICENSE","src/run.js"],"evidence":["upstream/0/LICENSE","upstream/0/src/run.js"],"changes":"Original implementation declared by the packager; no reused repository or dependency code."}]},"inputs":{"timeline":{"type":"timeline","context":"timeline","primary":true},"assets":{"type":"asset[]","context":"assets","required":false,"default":[]},"blueprint":{"type":"bible","context":"blueprint","required":false},"shotPlan":{"type":"shot-plan","context":"shotPlan","required":false},"metadata":{"type":"metadata","context":"metadata","required":false},"title":{"type":"text","default":"","description":"Optional download name."},"format":{"type":"text","default":"XML","description":"XML keeps media links; XML + media includes selected media in a ZIP."}},"outputs":{"download":{"type":"file-bundle","primary":true}},"permissions":{"project":["context.timeline.read","context.assets.read","context.blueprint.read","context.shotPlan.read","context.metadata.read","asset.read"],"network":[],"filesystem":[],"secrets":[]},"ui":[{"port":"title","control":"text"},{"port":"format","control":"select","options":["XML","XML + media"]}]},"code":"// Generated from this Block's owned serializer, notice formatter and download implementation.\n// Block-owned timeline serializer. No app services or I/O.\nconst APP = 'AI Video Workflow';\nconst FPS = 30;\n\nconst xmlEsc = (s) => String(s ?? '')\n  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n  .replace(/\"/g, '&quot;').replace(/'/g, '&apos;');\n\n// Decode one XML attribute layer, leaving a literal \"&amp;\" in an original URL intact.\nconst xmlUnesc = value => String(value ?? '').replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => ({amp:'&',lt:'<',gt:'>',quot:'\"',apos:\"'\"})[name]);\n\n// UTF-8 base64 without browser globals; this source also runs in QuickJS.\nconst alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';\nfunction b64encode(obj) {\n  const json = JSON.stringify(obj);\n  const bytes = encodeURIComponent(json).replace(/%([0-9A-F]{2})/g, (_, byte) => String.fromCharCode(parseInt(byte, 16)));\n  let result = '';\n  for (let i = 0; i < bytes.length; i += 3) {\n    const a = bytes.charCodeAt(i), b = bytes.charCodeAt(i + 1), c = bytes.charCodeAt(i + 2);\n    result += alphabet[a >> 2] + alphabet[((a & 3) << 4) | (Number.isNaN(b) ? 0 : b >> 4)]\n      + (Number.isNaN(b) ? '=' : alphabet[((b & 15) << 2) | (Number.isNaN(c) ? 0 : c >> 6)])\n      + (Number.isNaN(c) ? '=' : alphabet[c & 63]);\n  }\n  return result;\n}\nfunction b64decode(value) {\n  try {\n    if (typeof value !== 'string' || value.length % 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;\n    let bytes = '';\n    for (let i = 0; i < value.length; i += 4) {\n      const a = alphabet.indexOf(value[i]), b = alphabet.indexOf(value[i + 1]), c = alphabet.indexOf(value[i + 2]), d = alphabet.indexOf(value[i + 3]);\n      bytes += '%' + ((a << 2) | (b >> 4)).toString(16).padStart(2, '0');\n      if (c >= 0) bytes += '%' + (((b & 15) << 4) | (c >> 2)).toString(16).padStart(2, '0');\n      if (d >= 0) bytes += '%' + (((c & 3) << 6) | d).toString(16).padStart(2, '0');\n    }\n    return JSON.parse(decodeURIComponent(bytes));\n  } catch { return null; }\n}\n\nconst TRANSITION_NAME = {\n  Cut: 'Cut', 'Cross dissolve': 'Cross Dissolve', Fade: 'Fade',\n  'Dip to black': 'Dip to Black', 'Dip to white': 'Dip to White',\n  'Push left': 'Push Left', 'Push right': 'Push Right',\n};\n\nfunction buildFcpxml({ editor, projectName, bible, voiceoverShotMap, audioScore, appVersion = '1.0', createdAt = new Date().toISOString(), uid = Date.now() }) {\n  const title = projectName || bible?.title || 'Untitled Timeline';\n  const clips = [...(editor?.clips || [])]\n    .filter(c => c.kind === 'video' || c.kind === 'image')\n    .sort((a, b) => (a.start ?? 0) - (b.start ?? 0));\n  const audio = editor?.audioItems || [];\n  const texts = editor?.textOverlays || [];\n  const captions = editor?.captions || [];\n  // Text/caption items store start/end (not duration); fall back gracefully.\n  const itemDur = (it, def = 3) => {\n    if (it?.duration != null) return Math.max(0.1, it.duration);\n    if (it?.end != null) return Math.max(0.1, (it.end || 0) - (it.start || 0));\n    return def;\n  };\n  const total = Math.max(\n    ...clips.map(c => (c.start || 0) + (c.duration || 0)),\n    ...audio.map(a => (a.start || 0) + (a.duration || 0)),\n    ...texts.map(t => (t.start || 0) + itemDur(t)),\n    ...captions.map(c => (c.start || 0) + itemDur(c)),\n    1,\n  );\n\n  // ── resources ────────────────────────────────────────────────────────────\n  const assetByUrl = new Map();\n  let rid = 2;\n  const assetLines = [];\n  const refFor = (url, name, hasAudio) => {\n    if (!url) return null;\n    if (assetByUrl.has(url)) return assetByUrl.get(url);\n    const id = `r${rid++}`;\n    assetByUrl.set(url, id);\n    assetLines.push(\n      `    <asset id=\"${id}\" name=\"${xmlEsc(name)}\" src=\"${xmlEsc(url)}\" hasVideo=\"${hasAudio ? 0 : 1}\" hasAudio=\"${hasAudio ? 1 : 0}\"/>`,\n    );\n    return id;\n  };\n\n  const spine = [];\n  clips.forEach((c, i) => {\n    const ref = refFor(c.url, c.title || c.shotId || `clip_${i + 1}`, false);\n    const dur = +(c.duration || 4).toFixed(3);\n    const off = +(c.start || 0).toFixed(3);\n    const startIn = +(c.trimStart || 0).toFixed(3);\n    const inner = [];\n    const col = c.color || {};\n    if (col.brightness || col.contrast || col.saturation || col.temperature) {\n      inner.push(`        <adjust-colorcontrols enabled=\"1\" brightness=\"${col.brightness || 0}\" contrast=\"${col.contrast || 0}\" saturation=\"${col.saturation || 0}\" temperature=\"${col.temperature || 0}\"/>`);\n    }\n    if (col.vignette) inner.push(`        <filter-video name=\"Vignette\" intensity=\"${col.vignette}\"/>`);\n    if (col.sharpness) inner.push(`        <filter-video name=\"Sharpen\" intensity=\"${col.sharpness}\"/>`);\n    if (c.lut && c.lut !== 'None') inner.push(`        <filter-video name=\"LUT\" preset=\"${xmlEsc(c.lut)}\"/>`);\n    if ((c.speed || 1) !== 1) {\n      const srcSpan = +(dur * (c.speed || 1)).toFixed(3);\n      inner.push(`        <timeMap><timept time=\"0s\" value=\"${startIn}s\" interp=\"smooth2\"/><timept time=\"${dur}s\" value=\"${+(startIn + srcSpan).toFixed(3)}s\" interp=\"smooth2\"/></timeMap>`);\n    }\n    spine.push(\n      ref\n        ? `      <clip name=\"${xmlEsc(c.title || c.shotId || `clip_${i + 1}`)}\" ref=\"${ref}\" offset=\"${off}s\" duration=\"${dur}s\" start=\"${startIn}s\"${c.reverse ? ' timeReversed=\"1\"' : ''}>${inner.length ? `\\n${inner.join('\\n')}\\n      ` : ''}</clip>`\n        : `      <gap name=\"Offline ${xmlEsc(c.title || 'clip')}\" offset=\"${off}s\" duration=\"${dur}s\"/>`,\n    );\n    const t = c.transition;\n    if (t && t.type && t.type !== 'Cut' && i < clips.length - 1) {\n      spine.push(`      <transition name=\"${xmlEsc(TRANSITION_NAME[t.type] || t.type)}\" duration=\"${+(t.duration || 0.5).toFixed(3)}s\"/>`);\n    }\n  });\n  // Text overlays + captions → <title> elements so a foreign NLE (DaVinci/\n  // Premiere) shows them. Both store start/end; size is often a token\n  // ('m'/'xl') not a px number, so only pass it through when numeric.\n  const titleEntry = (it, name, role) =>\n    `      <title name=\"${xmlEsc(name)}\"${role ? ` role=\"${xmlEsc(role)}\"` : ''} offset=\"${+(it.start || 0).toFixed(3)}s\" duration=\"${+itemDur(it).toFixed(3)}s\">\\n` +\n    `        <text>${xmlEsc(it.text || '')}</text>\\n` +\n    `        <text-style font=\"${xmlEsc(it.fontFamily || 'Helvetica')}\" size=\"${typeof it.size === 'number' ? it.size : 32}\" color=\"${xmlEsc(it.color || '#ffffff')}\"/>\\n` +\n    `      </title>`;\n  texts.forEach(tx => spine.push(titleEntry(tx, tx.type || 'Title')));\n  captions.forEach(cap => spine.push(titleEntry(cap, 'Caption', 'caption')));\n\n  const audioTracks = audio.map((a, i) => {\n    const ref = refFor(a.url, a.title || a.kind || `audio_${i + 1}`, true);\n    if (!ref) return '';\n    const fades = [];\n    if (a.fadeIn) fades.push(`          <fade-in duration=\"${+a.fadeIn.toFixed(3)}s\"/>`);\n    if (a.fadeOut) fades.push(`          <fade-out duration=\"${+a.fadeOut.toFixed(3)}s\"/>`);\n    return (\n      `      <audio-track name=\"${xmlEsc(a.title || a.kind || 'Audio')}\" role=\"${xmlEsc(a.kind || 'music')}\">\\n` +\n      `        <audio-clip ref=\"${ref}\" offset=\"${+(a.start || 0).toFixed(3)}s\" duration=\"${+(a.duration || total).toFixed(3)}s\" volume=\"${a.volume ?? 100}\"${a.muted ? ' muted=\"1\"' : ''}>${fades.length ? `\\n${fades.join('\\n')}\\n        ` : ''}</audio-clip>\\n` +\n      `      </audio-track>`\n    );\n  }).filter(Boolean);\n\n  // Loss-less app state for perfect re-import into this app.\n  const appState = b64encode({\n    v: 1, app: APP, appVersion,\n    editor: stripEditor(editor),\n    bible: bible || null,\n    voiceoverShotMap: voiceoverShotMap || null,\n    audioScore: audioScore || null,\n  });\n\n  return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE fcpxml>\n<fcpxml version=\"1.11\">\n  <resources>\n    <format id=\"r1\" frameDuration=\"1001/30000s\" width=\"1920\" height=\"1080\"/>\n${assetLines.join('\\n')}\n  </resources>\n  <library>\n    <event name=\"Project Export\">\n      <project name=\"${xmlEsc(title)}\" uid=\"${xmlEsc(uid)}\">\n        <sequence format=\"r1\" duration=\"${+total.toFixed(3)}s\" tcStart=\"0s\">\n          <spine>\n${spine.join('\\n')}\n          </spine>\n${audioTracks.join('\\n')}\n        </sequence>\n        <metadata>\n          <md key=\"app\" value=\"${xmlEsc(APP)}\"/>\n          <md key=\"appVersion\" value=\"${xmlEsc(appVersion)}\"/>\n          <md key=\"created\" value=\"${xmlEsc(createdAt)}\"/>\n          <md key=\"bible\" value=\"${xmlEsc(JSON.stringify(bible || null))}\"/>\n          <md key=\"moodmap\" value=\"${xmlEsc(JSON.stringify(audioScore?.moodMap || null))}\"/>\n          <md key=\"generationprompts\" value=\"${xmlEsc(JSON.stringify(audioScore?.prompts || null))}\"/>\n          <md key=\"aivw_state\" value=\"${appState}\"/>\n        </metadata>\n      </project>\n    </event>\n  </library>\n</fcpxml>`;\n}\n\n// Drop heavy/derived fields; keep everything needed to rebuild the timeline.\nfunction stripEditor(editor) {\n  if (!editor) return null;\n  const { mediaItems, automationSuggestions, ...keep } = editor;\n  return keep;\n}\n\n// CREDITS.txt body for an export — one line per audio item that carries a\n// license record. Returns '' when nothing licensed is on the timeline.\nfunction buildMusicCredits(audioItems) {\n  const lines = (audioItems || [])\n    .filter((a) => a && a.license && a.url)\n    .map((a) => `\"${a.name || 'Track'}\"${a.attribution ? ` by ${a.attribution}` : ''} — ${a.license}${a.sourceUrl ? ` — ${a.sourceUrl}` : ''}${a.licenseUrl ? ` (${a.licenseUrl})` : ''}`);\n  if (!lines.length) return '';\n  return `MUSIC & AUDIO CREDITS\\n\\n${lines.join('\\n')}\\n\\nThese are the license and attribution records saved with the audio items. Refer to each source license for its permissions and requirements.\\n`;\n}\n\n// Block-owned result construction. The SDK host alone transfers media and saves files.\n\nconst list = value => Array.isArray(value) ? value : [];\nconst extension = url => String(url).split(/[?#]/)[0].match(/\\.(png|jpe?g|webp|gif|mp4|webm|mov|mp3|wav|ogg|m4a|flac)$/i)?.[0].toLowerCase() || '.bin';\nconst filename = title => {\n  const value = String(title || 'timeline').normalize('NFKD').replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100) || 'timeline';\n  return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(value) ? `timeline-${value}` : value;\n};\n\n/** Produce inert SDK file-bundle data, without network, DOM, or filesystem access. */\nfunction buildExportBundle({editor, projectName, bible, voiceoverShotMap, audioScore, appVersion, createdAt, uid, format = 'file', assets = []}) {\n  if (!['file', 'zip'].includes(format)) throw Error('Choose XML or XML with media.');\n  const name = filename(projectName || bible?.title);\n  const options = {editor, projectName, bible, voiceoverShotMap, audioScore, appVersion, createdAt, uid};\n  if (format === 'file') return {schemaVersion: 1, format, name: `${name}.fcpxml`, files: [{path: `${name}.fcpxml`, text: buildFcpxml(options)}]};\n\n  const files = [], selected = new Map(), paths = new Map();\n  for (const asset of assets) if (asset?.url && !selected.has(asset.url)) selected.set(asset.url, asset);\n  // Include native-state media too: stickers and alternate timeline snapshots\n  // must not keep remote URLs in a supposedly self-contained project export.\n  const add = (url, kind) => {\n    if (!url || paths.has(url)) return;\n    const source = selected.get(url);\n    if (!source || source.kind !== kind || !source.assetId || !source.versionId) throw Error('Some timeline media is outside the selected project context. Select the complete project, or export XML with linked media.');\n    const path = `media/${String(paths.size + 1).padStart(3, '0')}${extension(url)}`;\n    paths.set(url, path);\n    files.push({path, source: JSON.parse(JSON.stringify(source))});\n  };\n  const collect = timeline => {\n    for (const clip of list(timeline?.clips)) if (['video', 'image'].includes(clip.kind)) add(clip.url, clip.kind);\n    for (const audio of list(timeline?.audioItems)) add(audio.url, 'audio');\n    for (const sticker of list(timeline?.stickers)) if (sticker.url) add(sticker.url, ['image', 'video'].includes(sticker.kind) ? sticker.kind : 'image');\n    for (const version of list(timeline?.timelineVersions)) collect(version);\n  };\n  collect(editor);\n  // Rewrite only media-bearing fields. Caption text, prompts, names and source\n  // attribution must survive even when their entire value equals a media URL.\n  const credits = buildMusicCredits(editor?.audioItems);\n  files.unshift({path: `${name}.fcpxml`, text: buildFcpxml(rewriteExportMediaReferences(options, paths))});\n  if (credits) files.push({path: 'CREDITS.txt', text: credits});\n  return {schemaVersion: 1, format, name: `${name}.zip`, files};\n}\n\n// Shared with the native ZIP reader/writer until its UI migration is complete.\nfunction rewriteExportMediaReferences(value, paths, key = '') {\n    if (typeof value === 'string') return ['url', 'audioUrl', 'imageUrl', 'videoUrl', 'voPath'].includes(key) && paths.has(value) ? paths.get(value) : value;\n    if (Array.isArray(value)) return value.map(item => rewriteExportMediaReferences(item, paths, key));\n    if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([field, item]) => [field, rewriteExportMediaReferences(item, paths, field)]));\n    return value;\n}\n\nreturn {download:buildExportBundle(({editor:input.timeline,projectName:input.title||input.metadata?.name||'Timeline',bible:input.blueprint,voiceoverShotMap:input.shotPlan,audioScore:input.timeline.audioScore,format:input.format==='XML'?'file':input.format==='XML + media'?'zip':'invalid',assets:input.assets||[],createdAt:'',uid:input.timeline.activeTimelineId||input.metadata?.id||'timeline'}))};\n","tests":[{"name":"XML with linked media","input":{"timeline":{"schemaVersion":1,"clips":[],"audioItems":[],"captions":[],"tracks":[]},"title":"Empty timeline","format":"XML","assets":[]},"expected":{"download":{"schemaVersion":1,"format":"file","name":"Empty-timeline.fcpxml","files":[{"path":"Empty-timeline.fcpxml","text":"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE fcpxml>\n<fcpxml version=\"1.11\">\n  <resources>\n    <format id=\"r1\" frameDuration=\"1001/30000s\" width=\"1920\" height=\"1080\"/>\n\n  </resources>\n  <library>\n    <event name=\"Project Export\">\n      <project name=\"Empty timeline\" uid=\"timeline\">\n        <sequence format=\"r1\" duration=\"1s\" tcStart=\"0s\">\n          <spine>\n\n          </spine>\n\n        </sequence>\n        <metadata>\n          <md key=\"app\" value=\"AI Video Workflow\"/>\n          <md key=\"appVersion\" value=\"1.0\"/>\n          <md key=\"created\" value=\"\"/>\n          <md key=\"bible\" value=\"null\"/>\n          <md key=\"moodmap\" value=\"null\"/>\n          <md key=\"generationprompts\" value=\"null\"/>\n          <md key=\"aivw_state\" value=\"eyJ2IjoxLCJhcHAiOiJBSSBWaWRlbyBXb3JrZmxvdyIsImFwcFZlcnNpb24iOiIxLjAiLCJlZGl0b3IiOnsic2NoZW1hVmVyc2lvbiI6MSwiY2xpcHMiOltdLCJhdWRpb0l0ZW1zIjpbXSwiY2FwdGlvbnMiOltdLCJ0cmFja3MiOltdfSwiYmlibGUiOm51bGwsInZvaWNlb3ZlclNob3RNYXAiOm51bGwsImF1ZGlvU2NvcmUiOm51bGx9\"/>\n        </metadata>\n      </project>\n    </event>\n  </library>\n</fcpxml>"}]}}},{"name":"ZIP without media","input":{"timeline":{"schemaVersion":1,"clips":[],"audioItems":[],"captions":[],"tracks":[]},"title":"Empty timeline","format":"XML + media","assets":[]},"expected":{"download":{"schemaVersion":1,"format":"zip","name":"Empty-timeline.zip","files":[{"path":"Empty-timeline.fcpxml","text":"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE fcpxml>\n<fcpxml version=\"1.11\">\n  <resources>\n    <format id=\"r1\" frameDuration=\"1001/30000s\" width=\"1920\" height=\"1080\"/>\n\n  </resources>\n  <library>\n    <event name=\"Project Export\">\n      <project name=\"Empty timeline\" uid=\"timeline\">\n        <sequence format=\"r1\" duration=\"1s\" tcStart=\"0s\">\n          <spine>\n\n          </spine>\n\n        </sequence>\n        <metadata>\n          <md key=\"app\" value=\"AI Video Workflow\"/>\n          <md key=\"appVersion\" value=\"1.0\"/>\n          <md key=\"created\" value=\"\"/>\n          <md key=\"bible\" value=\"null\"/>\n          <md key=\"moodmap\" value=\"null\"/>\n          <md key=\"generationprompts\" value=\"null\"/>\n          <md key=\"aivw_state\" value=\"eyJ2IjoxLCJhcHAiOiJBSSBWaWRlbyBXb3JrZmxvdyIsImFwcFZlcnNpb24iOiIxLjAiLCJlZGl0b3IiOnsic2NoZW1hVmVyc2lvbiI6MSwiY2xpcHMiOltdLCJhdWRpb0l0ZW1zIjpbXSwiY2FwdGlvbnMiOltdLCJ0cmFja3MiOltdfSwiYmlibGUiOm51bGwsInZvaWNlb3ZlclNob3RNYXAiOm51bGwsImF1ZGlvU2NvcmUiOm51bGx9\"/>\n        </metadata>\n      </project>\n    </event>\n  </library>\n</fcpxml>"}]}}},{"name":"ZIP preserving selected media and project documents","input":{"timeline":{"schemaVersion":1,"clips":[{"id":"clip","kind":"video","url":"https://media.example/clip.mp4","title":"Clip","start":0,"duration":3}],"audioItems":[],"captions":[{"id":"caption","text":"Hello 世界","start":0,"end":2}],"tracks":[],"audioScore":{"moodMap":[]}},"title":"Selected media","format":"XML + media","assets":[{"kind":"video","assetId":"clip","versionId":"v1","url":"https://media.example/clip.mp4"}],"blueprint":{"schemaVersion":1,"title":"Original"},"shotPlan":{"schemaVersion":1,"scenes":[{"id":"scene","shots":[{"id":"shot","text":"Keep narration"}]}]}},"expected":{"download":{"schemaVersion":1,"format":"zip","name":"Selected-media.zip","files":[{"path":"Selected-media.fcpxml","text":"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE fcpxml>\n<fcpxml version=\"1.11\">\n  <resources>\n    <format id=\"r1\" frameDuration=\"1001/30000s\" width=\"1920\" height=\"1080\"/>\n    <asset id=\"r2\" name=\"Clip\" src=\"media/001.mp4\" hasVideo=\"1\" hasAudio=\"0\"/>\n  </resources>\n  <library>\n    <event name=\"Project Export\">\n      <project name=\"Selected media\" uid=\"timeline\">\n        <sequence format=\"r1\" duration=\"3s\" tcStart=\"0s\">\n          <spine>\n      <clip name=\"Clip\" ref=\"r2\" offset=\"0s\" duration=\"3s\" start=\"0s\"></clip>\n      <title name=\"Caption\" role=\"caption\" offset=\"0s\" duration=\"2s\">\n        <text>Hello 世界</text>\n        <text-style font=\"Helvetica\" size=\"32\" color=\"#ffffff\"/>\n      </title>\n          </spine>\n\n        </sequence>\n        <metadata>\n          <md key=\"app\" value=\"AI Video Workflow\"/>\n          <md key=\"appVersion\" value=\"1.0\"/>\n          <md key=\"created\" value=\"\"/>\n          <md key=\"bible\" value=\"{&quot;schemaVersion&quot;:1,&quot;title&quot;:&quot;Original&quot;}\"/>\n          <md key=\"moodmap\" value=\"[]\"/>\n          <md key=\"generationprompts\" value=\"null\"/>\n          <md key=\"aivw_state\" value=\"eyJ2IjoxLCJhcHAiOiJBSSBWaWRlbyBXb3JrZmxvdyIsImFwcFZlcnNpb24iOiIxLjAiLCJlZGl0b3IiOnsic2NoZW1hVmVyc2lvbiI6MSwiY2xpcHMiOlt7ImlkIjoiY2xpcCIsImtpbmQiOiJ2aWRlbyIsInVybCI6Im1lZGlhLzAwMS5tcDQiLCJ0aXRsZSI6IkNsaXAiLCJzdGFydCI6MCwiZHVyYXRpb24iOjN9XSwiYXVkaW9JdGVtcyI6W10sImNhcHRpb25zIjpbeyJpZCI6ImNhcHRpb24iLCJ0ZXh0IjoiSGVsbG8g5LiW55WMIiwic3RhcnQiOjAsImVuZCI6Mn1dLCJ0cmFja3MiOltdLCJhdWRpb1Njb3JlIjp7Im1vb2RNYXAiOltdfX0sImJpYmxlIjp7InNjaGVtYVZlcnNpb24iOjEsInRpdGxlIjoiT3JpZ2luYWwifSwidm9pY2VvdmVyU2hvdE1hcCI6eyJzY2hlbWFWZXJzaW9uIjoxLCJzY2VuZXMiOlt7ImlkIjoic2NlbmUiLCJzaG90cyI6W3siaWQiOiJzaG90IiwidGV4dCI6IktlZXAgbmFycmF0aW9uIn1dfV19LCJhdWRpb1Njb3JlIjp7Im1vb2RNYXAiOltdfX0=\"/>\n        </metadata>\n      </project>\n    </event>\n  </library>\n</fcpxml>"},{"path":"media/001.mp4","source":{"kind":"video","assetId":"clip","versionId":"v1","url":"https://media.example/clip.mp4"}}]}}}],"portable":{"format":"stillmade-block/1","dependencies":[],"sources":[{"kind":"original","license":"MIT","paths":["LICENSE","src/run.js"],"evidence":["upstream/0/LICENSE","upstream/0/src/run.js"],"changes":"Original implementation declared by the packager; no reused repository or dependency code."}],"files":{"upstream/0/LICENSE":"MIT License\n\nCopyright (c) 2026 StillMade contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and \nassociated documentation files (the \"Software\"), to deal in the Software without restriction, including \nwithout limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell \ncopies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the \nfollowing conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial \nportions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT \nLIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO \nEVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER \nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE \nUSE OR OTHER DEALINGS IN THE SOFTWARE.\n","upstream/0/src/run.js":"// Generated from this Block's owned serializer, notice formatter and download implementation.\n// Block-owned timeline serializer. No app services or I/O.\nconst APP = 'AI Video Workflow';\nconst FPS = 30;\n\nconst xmlEsc = (s) => String(s ?? '')\n  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n  .replace(/\"/g, '&quot;').replace(/'/g, '&apos;');\n\n// Decode one XML attribute layer, leaving a literal \"&amp;\" in an original URL intact.\nconst xmlUnesc = value => String(value ?? '').replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => ({amp:'&',lt:'<',gt:'>',quot:'\"',apos:\"'\"})[name]);\n\n// UTF-8 base64 without browser globals; this source also runs in QuickJS.\nconst alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';\nfunction b64encode(obj) {\n  const json = JSON.stringify(obj);\n  const bytes = encodeURIComponent(json).replace(/%([0-9A-F]{2})/g, (_, byte) => String.fromCharCode(parseInt(byte, 16)));\n  let result = '';\n  for (let i = 0; i < bytes.length; i += 3) {\n    const a = bytes.charCodeAt(i), b = bytes.charCodeAt(i + 1), c = bytes.charCodeAt(i + 2);\n    result += alphabet[a >> 2] + alphabet[((a & 3) << 4) | (Number.isNaN(b) ? 0 : b >> 4)]\n      + (Number.isNaN(b) ? '=' : alphabet[((b & 15) << 2) | (Number.isNaN(c) ? 0 : c >> 6)])\n      + (Number.isNaN(c) ? '=' : alphabet[c & 63]);\n  }\n  return result;\n}\nfunction b64decode(value) {\n  try {\n    if (typeof value !== 'string' || value.length % 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;\n    let bytes = '';\n    for (let i = 0; i < value.length; i += 4) {\n      const a = alphabet.indexOf(value[i]), b = alphabet.indexOf(value[i + 1]), c = alphabet.indexOf(value[i + 2]), d = alphabet.indexOf(value[i + 3]);\n      bytes += '%' + ((a << 2) | (b >> 4)).toString(16).padStart(2, '0');\n      if (c >= 0) bytes += '%' + (((b & 15) << 4) | (c >> 2)).toString(16).padStart(2, '0');\n      if (d >= 0) bytes += '%' + (((c & 3) << 6) | d).toString(16).padStart(2, '0');\n    }\n    return JSON.parse(decodeURIComponent(bytes));\n  } catch { return null; }\n}\n\nconst TRANSITION_NAME = {\n  Cut: 'Cut', 'Cross dissolve': 'Cross Dissolve', Fade: 'Fade',\n  'Dip to black': 'Dip to Black', 'Dip to white': 'Dip to White',\n  'Push left': 'Push Left', 'Push right': 'Push Right',\n};\n\nfunction buildFcpxml({ editor, projectName, bible, voiceoverShotMap, audioScore, appVersion = '1.0', createdAt = new Date().toISOString(), uid = Date.now() }) {\n  const title = projectName || bible?.title || 'Untitled Timeline';\n  const clips = [...(editor?.clips || [])]\n    .filter(c => c.kind === 'video' || c.kind === 'image')\n    .sort((a, b) => (a.start ?? 0) - (b.start ?? 0));\n  const audio = editor?.audioItems || [];\n  const texts = editor?.textOverlays || [];\n  const captions = editor?.captions || [];\n  // Text/caption items store start/end (not duration); fall back gracefully.\n  const itemDur = (it, def = 3) => {\n    if (it?.duration != null) return Math.max(0.1, it.duration);\n    if (it?.end != null) return Math.max(0.1, (it.end || 0) - (it.start || 0));\n    return def;\n  };\n  const total = Math.max(\n    ...clips.map(c => (c.start || 0) + (c.duration || 0)),\n    ...audio.map(a => (a.start || 0) + (a.duration || 0)),\n    ...texts.map(t => (t.start || 0) + itemDur(t)),\n    ...captions.map(c => (c.start || 0) + itemDur(c)),\n    1,\n  );\n\n  // ── resources ────────────────────────────────────────────────────────────\n  const assetByUrl = new Map();\n  let rid = 2;\n  const assetLines = [];\n  const refFor = (url, name, hasAudio) => {\n    if (!url) return null;\n    if (assetByUrl.has(url)) return assetByUrl.get(url);\n    const id = `r${rid++}`;\n    assetByUrl.set(url, id);\n    assetLines.push(\n      `    <asset id=\"${id}\" name=\"${xmlEsc(name)}\" src=\"${xmlEsc(url)}\" hasVideo=\"${hasAudio ? 0 : 1}\" hasAudio=\"${hasAudio ? 1 : 0}\"/>`,\n    );\n    return id;\n  };\n\n  const spine = [];\n  clips.forEach((c, i) => {\n    const ref = refFor(c.url, c.title || c.shotId || `clip_${i + 1}`, false);\n    const dur = +(c.duration || 4).toFixed(3);\n    const off = +(c.start || 0).toFixed(3);\n    const startIn = +(c.trimStart || 0).toFixed(3);\n    const inner = [];\n    const col = c.color || {};\n    if (col.brightness || col.contrast || col.saturation || col.temperature) {\n      inner.push(`        <adjust-colorcontrols enabled=\"1\" brightness=\"${col.brightness || 0}\" contrast=\"${col.contrast || 0}\" saturation=\"${col.saturation || 0}\" temperature=\"${col.temperature || 0}\"/>`);\n    }\n    if (col.vignette) inner.push(`        <filter-video name=\"Vignette\" intensity=\"${col.vignette}\"/>`);\n    if (col.sharpness) inner.push(`        <filter-video name=\"Sharpen\" intensity=\"${col.sharpness}\"/>`);\n    if (c.lut && c.lut !== 'None') inner.push(`        <filter-video name=\"LUT\" preset=\"${xmlEsc(c.lut)}\"/>`);\n    if ((c.speed || 1) !== 1) {\n      const srcSpan = +(dur * (c.speed || 1)).toFixed(3);\n      inner.push(`        <timeMap><timept time=\"0s\" value=\"${startIn}s\" interp=\"smooth2\"/><timept time=\"${dur}s\" value=\"${+(startIn + srcSpan).toFixed(3)}s\" interp=\"smooth2\"/></timeMap>`);\n    }\n    spine.push(\n      ref\n        ? `      <clip name=\"${xmlEsc(c.title || c.shotId || `clip_${i + 1}`)}\" ref=\"${ref}\" offset=\"${off}s\" duration=\"${dur}s\" start=\"${startIn}s\"${c.reverse ? ' timeReversed=\"1\"' : ''}>${inner.length ? `\\n${inner.join('\\n')}\\n      ` : ''}</clip>`\n        : `      <gap name=\"Offline ${xmlEsc(c.title || 'clip')}\" offset=\"${off}s\" duration=\"${dur}s\"/>`,\n    );\n    const t = c.transition;\n    if (t && t.type && t.type !== 'Cut' && i < clips.length - 1) {\n      spine.push(`      <transition name=\"${xmlEsc(TRANSITION_NAME[t.type] || t.type)}\" duration=\"${+(t.duration || 0.5).toFixed(3)}s\"/>`);\n    }\n  });\n  // Text overlays + captions → <title> elements so a foreign NLE (DaVinci/\n  // Premiere) shows them. Both store start/end; size is often a token\n  // ('m'/'xl') not a px number, so only pass it through when numeric.\n  const titleEntry = (it, name, role) =>\n    `      <title name=\"${xmlEsc(name)}\"${role ? ` role=\"${xmlEsc(role)}\"` : ''} offset=\"${+(it.start || 0).toFixed(3)}s\" duration=\"${+itemDur(it).toFixed(3)}s\">\\n` +\n    `        <text>${xmlEsc(it.text || '')}</text>\\n` +\n    `        <text-style font=\"${xmlEsc(it.fontFamily || 'Helvetica')}\" size=\"${typeof it.size === 'number' ? it.size : 32}\" color=\"${xmlEsc(it.color || '#ffffff')}\"/>\\n` +\n    `      </title>`;\n  texts.forEach(tx => spine.push(titleEntry(tx, tx.type || 'Title')));\n  captions.forEach(cap => spine.push(titleEntry(cap, 'Caption', 'caption')));\n\n  const audioTracks = audio.map((a, i) => {\n    const ref = refFor(a.url, a.title || a.kind || `audio_${i + 1}`, true);\n    if (!ref) return '';\n    const fades = [];\n    if (a.fadeIn) fades.push(`          <fade-in duration=\"${+a.fadeIn.toFixed(3)}s\"/>`);\n    if (a.fadeOut) fades.push(`          <fade-out duration=\"${+a.fadeOut.toFixed(3)}s\"/>`);\n    return (\n      `      <audio-track name=\"${xmlEsc(a.title || a.kind || 'Audio')}\" role=\"${xmlEsc(a.kind || 'music')}\">\\n` +\n      `        <audio-clip ref=\"${ref}\" offset=\"${+(a.start || 0).toFixed(3)}s\" duration=\"${+(a.duration || total).toFixed(3)}s\" volume=\"${a.volume ?? 100}\"${a.muted ? ' muted=\"1\"' : ''}>${fades.length ? `\\n${fades.join('\\n')}\\n        ` : ''}</audio-clip>\\n` +\n      `      </audio-track>`\n    );\n  }).filter(Boolean);\n\n  // Loss-less app state for perfect re-import into this app.\n  const appState = b64encode({\n    v: 1, app: APP, appVersion,\n    editor: stripEditor(editor),\n    bible: bible || null,\n    voiceoverShotMap: voiceoverShotMap || null,\n    audioScore: audioScore || null,\n  });\n\n  return `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE fcpxml>\n<fcpxml version=\"1.11\">\n  <resources>\n    <format id=\"r1\" frameDuration=\"1001/30000s\" width=\"1920\" height=\"1080\"/>\n${assetLines.join('\\n')}\n  </resources>\n  <library>\n    <event name=\"Project Export\">\n      <project name=\"${xmlEsc(title)}\" uid=\"${xmlEsc(uid)}\">\n        <sequence format=\"r1\" duration=\"${+total.toFixed(3)}s\" tcStart=\"0s\">\n          <spine>\n${spine.join('\\n')}\n          </spine>\n${audioTracks.join('\\n')}\n        </sequence>\n        <metadata>\n          <md key=\"app\" value=\"${xmlEsc(APP)}\"/>\n          <md key=\"appVersion\" value=\"${xmlEsc(appVersion)}\"/>\n          <md key=\"created\" value=\"${xmlEsc(createdAt)}\"/>\n          <md key=\"bible\" value=\"${xmlEsc(JSON.stringify(bible || null))}\"/>\n          <md key=\"moodmap\" value=\"${xmlEsc(JSON.stringify(audioScore?.moodMap || null))}\"/>\n          <md key=\"generationprompts\" value=\"${xmlEsc(JSON.stringify(audioScore?.prompts || null))}\"/>\n          <md key=\"aivw_state\" value=\"${appState}\"/>\n        </metadata>\n      </project>\n    </event>\n  </library>\n</fcpxml>`;\n}\n\n// Drop heavy/derived fields; keep everything needed to rebuild the timeline.\nfunction stripEditor(editor) {\n  if (!editor) return null;\n  const { mediaItems, automationSuggestions, ...keep } = editor;\n  return keep;\n}\n\n// CREDITS.txt body for an export — one line per audio item that carries a\n// license record. Returns '' when nothing licensed is on the timeline.\nfunction buildMusicCredits(audioItems) {\n  const lines = (audioItems || [])\n    .filter((a) => a && a.license && a.url)\n    .map((a) => `\"${a.name || 'Track'}\"${a.attribution ? ` by ${a.attribution}` : ''} — ${a.license}${a.sourceUrl ? ` — ${a.sourceUrl}` : ''}${a.licenseUrl ? ` (${a.licenseUrl})` : ''}`);\n  if (!lines.length) return '';\n  return `MUSIC & AUDIO CREDITS\\n\\n${lines.join('\\n')}\\n\\nThese are the license and attribution records saved with the audio items. Refer to each source license for its permissions and requirements.\\n`;\n}\n\n// Block-owned result construction. The SDK host alone transfers media and saves files.\n\nconst list = value => Array.isArray(value) ? value : [];\nconst extension = url => String(url).split(/[?#]/)[0].match(/\\.(png|jpe?g|webp|gif|mp4|webm|mov|mp3|wav|ogg|m4a|flac)$/i)?.[0].toLowerCase() || '.bin';\nconst filename = title => {\n  const value = String(title || 'timeline').normalize('NFKD').replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100) || 'timeline';\n  return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(value) ? `timeline-${value}` : value;\n};\n\n/** Produce inert SDK file-bundle data, without network, DOM, or filesystem access. */\nfunction buildExportBundle({editor, projectName, bible, voiceoverShotMap, audioScore, appVersion, createdAt, uid, format = 'file', assets = []}) {\n  if (!['file', 'zip'].includes(format)) throw Error('Choose XML or XML with media.');\n  const name = filename(projectName || bible?.title);\n  const options = {editor, projectName, bible, voiceoverShotMap, audioScore, appVersion, createdAt, uid};\n  if (format === 'file') return {schemaVersion: 1, format, name: `${name}.fcpxml`, files: [{path: `${name}.fcpxml`, text: buildFcpxml(options)}]};\n\n  const files = [], selected = new Map(), paths = new Map();\n  for (const asset of assets) if (asset?.url && !selected.has(asset.url)) selected.set(asset.url, asset);\n  // Include native-state media too: stickers and alternate timeline snapshots\n  // must not keep remote URLs in a supposedly self-contained project export.\n  const add = (url, kind) => {\n    if (!url || paths.has(url)) return;\n    const source = selected.get(url);\n    if (!source || source.kind !== kind || !source.assetId || !source.versionId) throw Error('Some timeline media is outside the selected project context. Select the complete project, or export XML with linked media.');\n    const path = `media/${String(paths.size + 1).padStart(3, '0')}${extension(url)}`;\n    paths.set(url, path);\n    files.push({path, source: JSON.parse(JSON.stringify(source))});\n  };\n  const collect = timeline => {\n    for (const clip of list(timeline?.clips)) if (['video', 'image'].includes(clip.kind)) add(clip.url, clip.kind);\n    for (const audio of list(timeline?.audioItems)) add(audio.url, 'audio');\n    for (const sticker of list(timeline?.stickers)) if (sticker.url) add(sticker.url, ['image', 'video'].includes(sticker.kind) ? sticker.kind : 'image');\n    for (const version of list(timeline?.timelineVersions)) collect(version);\n  };\n  collect(editor);\n  // Rewrite only media-bearing fields. Caption text, prompts, names and source\n  // attribution must survive even when their entire value equals a media URL.\n  const credits = buildMusicCredits(editor?.audioItems);\n  files.unshift({path: `${name}.fcpxml`, text: buildFcpxml(rewriteExportMediaReferences(options, paths))});\n  if (credits) files.push({path: 'CREDITS.txt', text: credits});\n  return {schemaVersion: 1, format, name: `${name}.zip`, files};\n}\n\n// Shared with the native ZIP reader/writer until its UI migration is complete.\nfunction rewriteExportMediaReferences(value, paths, key = '') {\n    if (typeof value === 'string') return ['url', 'audioUrl', 'imageUrl', 'videoUrl', 'voPath'].includes(key) && paths.has(value) ? paths.get(value) : value;\n    if (Array.isArray(value)) return value.map(item => rewriteExportMediaReferences(item, paths, key));\n    if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([field, item]) => [field, rewriteExportMediaReferences(item, paths, field)]));\n    return value;\n}\n\nreturn {download:buildExportBundle(({editor:input.timeline,projectName:input.title||input.metadata?.name||'Timeline',bible:input.blueprint,voiceoverShotMap:input.shotPlan,audioScore:input.timeline.audioScore,format:input.format==='XML'?'file':input.format==='XML + media'?'zip':'invalid',assets:input.assets||[],createdAt:'',uid:input.timeline.activeTimelineId||input.metadata?.id||'timeline'}))};\n"},"lineage":{"parents":[],"changes":"Packaged original Block source for StillMade."}}}],"license":"MIT"}