feat(uxp-panel): single Export menu, Upload to MAM, Local Export, auto-upload

UI consolidation:
- One Export entry (rail) opens a popup menu: Conform Timeline -> MAM and
  Local Export. Retires the standalone Export & Conform / Fetch & Relink
  dock buttons and the plain "Push Timeline" flow.
- Remove Import All.
- New "Upload to MAM" dock button.

Upload: reads the highlighted project-panel item(s) via the premierepro
API (best-effort, guarded) and falls back to a native file picker. Pushes
via /upload/simple (small) or chunked init/part/complete (large).

Local Export: batch-trim the timeline's hi-res clips server-side (FFMPEG),
poll trim-status, download each temp-segment-url, relink in Premiere.
Relink keys on source media path (last-wins for multi-use sources).

Conform + Local Export auto-upload any timeline sources not yet in the MAM
before proceeding (renders from the hi-res original, available post-upload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-28 23:48:30 -04:00
parent 7e9f1277d4
commit 3f203f326e
7 changed files with 364 additions and 42 deletions

View file

@ -57,7 +57,7 @@
<span class="rail-spacer"></span>
<div id="export-timeline-btn" role="button" tabindex="0" class="rail-btn rail-btn--accent" data-tip="Export Timeline" data-tip-pos="right">
<div id="export-timeline-btn" role="button" tabindex="0" class="rail-btn rail-btn--accent" data-tip="Export" data-tip-pos="right">
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M9 16h6v-6h4l-7-7-7 7h4zm-4 2h14v2H5z"/></svg>
</div>
<div id="refresh-btn" role="button" tabindex="0" class="rail-btn" data-tip="Refresh" data-tip-pos="right">
@ -130,14 +130,8 @@
<span class="dock-sep"></span>
<div id="import-all-btn" role="button" tabindex="0" class="iconbtn" data-tip="Import All" data-tip-pos="up-left" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z"/></svg>
</div>
<div id="export-conform-btn" role="button" tabindex="0" class="iconbtn" data-tip="Export &amp; Conform" data-tip-pos="up-left" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>
</div>
<div id="fetch-relink-btn" role="button" tabindex="0" class="iconbtn" data-tip="Fetch &amp; Relink All" data-tip-pos="up-left" disabled>
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
<div id="upload-mam-btn" role="button" tabindex="0" class="iconbtn" data-tip="Upload to MAM" data-tip-pos="up-left">
<svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M19.35 10.04A7.49 7.49 0 0 0 12 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 0 0 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>
</div>
</footer>
@ -146,7 +140,13 @@
</div><!-- /app -->
</section>
<!-- ── Export Timeline Slide Panel ──────────────────────────────── -->
<!-- Export menu (anchored to the rail Export button, positioned in JS) -->
<div id="export-menu" class="menu menu--float hidden" role="menu">
<button id="menu-conform" class="menu-item" role="menuitem">Conform Timeline → MAM</button>
<button id="menu-local-export" class="menu-item" role="menuitem">Local Export</button>
</div>
<!-- ── (retired) Push Timeline Slide Panel — kept hidden/unused ──── -->
<div id="export-overlay" class="slide-overlay hidden"></div>
<div id="export-panel" class="slide-panel hidden">
<div class="slide-header">

View file

@ -167,5 +167,57 @@
});
};
// Local Export trim job polling + segment retrieval.
API.getTrimStatus = function (jobId) {
return API.json('/api/v1/assets/trim-status/' + jobId);
};
API.getTempSegmentUrl = function (clipInstanceId) {
return API.json('/api/v1/assets/temp-segment-url/' + clipInstanceId);
};
// ── Upload (ingest editor media into the MAM) ────────────────────
// Single-shot multipart form upload (server caps simple at <50 MB).
API.uploadSimple = async function (blob, meta) {
const fd = new FormData();
fd.append('file', blob, meta.filename);
fd.append('filename', meta.filename);
fd.append('projectId', meta.projectId);
if (meta.binId) fd.append('binId', meta.binId);
if (meta.contentType) fd.append('contentType', meta.contentType);
const r = await API.request('/api/v1/upload/simple', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload HTTP ' + r.status + ' — ' + (await r.text().catch(() => '')).slice(0, 160));
return r.json();
};
// Chunked multipart for large originals.
API.uploadInit = function (meta) {
return API.json('/api/v1/upload/init', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
});
};
API.uploadPart = async function (blob, meta) {
const fd = new FormData();
fd.append('file', blob, 'part-' + meta.partNumber);
fd.append('uploadId', meta.uploadId);
fd.append('key', meta.key);
fd.append('partNumber', String(meta.partNumber));
const r = await API.request('/api/v1/upload/part', { method: 'POST', body: fd });
if (!r.ok) throw new Error('Upload part HTTP ' + r.status);
return r.json();
};
API.uploadComplete = function (meta) {
return API.json('/api/v1/upload/complete', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
});
};
API.uploadAbort = function (meta) {
return API.json('/api/v1/upload/abort', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
}).catch(() => {});
};
window.API = API;
})();

View file

@ -154,5 +154,111 @@
return destPath;
};
// ── Upload (ingest editor media into the MAM) ────────────────────
const SIMPLE_MAX = 45 * 1024 * 1024; // server caps /simple at <50 MB
const PART_SIZE = 16 * 1024 * 1024; // chunk size for multipart
function _contentType(name) {
const ext = String(name).split('.').pop().toLowerCase();
const map = {
mp4:'video/mp4', m4v:'video/mp4', mov:'video/quicktime', mxf:'application/mxf',
mkv:'video/x-matroska', avi:'video/x-msvideo', mpg:'video/mpeg', mpeg:'video/mpeg',
mts:'video/mp2t', m2ts:'video/mp2t', wav:'audio/wav', aif:'audio/aiff', aiff:'audio/aiff',
mp3:'audio/mpeg', png:'image/png', jpg:'image/jpeg', jpeg:'image/jpeg',
};
return map[ext] || 'application/octet-stream';
}
// Read a local file and push it to the MAM. Returns the created asset row.
// NOTE: reads the whole file into memory once (fine for typical clips;
// very large multi-GB originals may strain memory — revisit with a
// positional-read stream if that becomes a problem).
Import.uploadFile = async function (nativePath, meta) {
meta = meta || {};
if (!meta.projectId) throw new Error('No target project for upload');
const filename = meta.filename || path.basename(nativePath);
const contentType = _contentType(filename);
const buf = await fs.readFile(nativePath);
const size = buf.byteLength != null ? buf.byteLength : buf.length;
if (size <= SIMPLE_MAX) {
const blob = new Blob([buf], { type: contentType });
return API.uploadSimple(blob, { filename, projectId: meta.projectId, binId: meta.binId, contentType });
}
// Chunked multipart for large files.
const init = await API.uploadInit({ filename, fileSize: size, contentType, projectId: meta.projectId, binId: meta.binId });
const parts = [];
try {
let partNumber = 1;
for (let off = 0; off < size; off += PART_SIZE, partNumber++) {
const chunk = buf.slice(off, Math.min(off + PART_SIZE, size));
const blob = new Blob([chunk], { type: contentType });
if (meta.onProgress) meta.onProgress(off, size);
const res = await API.uploadPart(blob, { uploadId: init.uploadId, key: init.key, partNumber });
parts.push({ PartNumber: partNumber, ETag: res.etag });
}
return API.uploadComplete({ uploadId: init.uploadId, key: init.key, assetId: init.assetId, parts });
} catch (e) {
await API.uploadAbort({ uploadId: init.uploadId, key: init.key, assetId: init.assetId });
throw e;
}
};
// ── Bin selection (best-effort) + file-picker fallback ───────────
// Tries to read the highlighted project-panel item(s). The UXP premierepro
// selection surface varies by version, so every access is guarded; on any
// miss this returns [] and callers fall back to a native file picker.
Import.getSelectedBinPaths = async function () {
const paths = [];
try {
const P = _ppro();
const project = await P.Project.getActiveProject();
if (!project) return paths;
let sel = null;
try { if (project.getSelection) sel = await project.getSelection(); } catch (_) {}
let items = [];
if (sel) {
if (typeof sel.getItems === 'function') items = await sel.getItems();
else if (Array.isArray(sel)) items = sel;
else if (Array.isArray(sel.items)) items = sel.items;
}
for (const it of (items || [])) {
try {
const ci = await P.ClipProjectItem.cast(it);
const mp = await ci.getMediaFilePath();
if (mp) paths.push(mp);
} catch (_) {}
}
} catch (_) {}
return paths;
};
// Native file picker — returns array of native paths (may be empty).
Import.pickFiles = async function () {
if (!uxpFs || !uxpFs.getFileForOpening) throw new Error('File picker unavailable in this host');
const sel = await uxpFs.getFileForOpening({ allowMultiple: true });
if (!sel) return [];
const arr = Array.isArray(sel) ? sel : [sel];
return arr.map(f => f && f.nativePath).filter(Boolean);
};
// Upload any timeline clips not yet in the MAM, recording the path→asset
// mapping so resolveClipsToAssets picks them up on the next pass.
Import.ensureClipsInMam = async function (clips, projectId, onProgress) {
const missing = clips.filter(c => !c.asset_id && c.filePath);
for (let i = 0; i < missing.length; i++) {
const c = missing[i];
if (onProgress) onProgress(c.fileName || path.basename(c.filePath), i + 1, missing.length);
const asset = await Import.uploadFile(c.filePath, { projectId, filename: path.basename(c.filePath) });
if (asset && asset.id) {
Library.recordImport(c.filePath, { assetId: asset.id });
if (c.fileName) Library.recordImport('name:' + c.fileName, { assetId: asset.id });
}
}
return { uploaded: missing.length };
};
window.Import = Import;
})();

View file

@ -220,10 +220,7 @@
_btn('import-hires-btn').disabled = !sel || live || !sel.original_s3_key;
_btn('mount-live-btn').disabled = !sel || !live;
_btn('relink-btn').disabled = !(ready && hasLiveImport);
_btn('import-all-btn').disabled = Library.state.currentTab !== 'library';
_btn('export-timeline-btn').disabled = false; // available once connected
_btn('export-conform-btn').disabled = false;
_btn('fetch-relink-btn').disabled = false;
// export-timeline-btn (Export menu) and upload-mam-btn are always available.
};
function _btn(id) { return document.getElementById(id) || { disabled: false }; }

View file

@ -13,8 +13,7 @@
// rest of the code sets onto a [disabled] attribute the stylesheet keys off.
const ICON_CONTROLS = [
'menu-btn', 'tab-library', 'tab-growing', 'export-timeline-btn', 'refresh-btn',
'import-proxy-btn', 'import-hires-btn', 'mount-live-btn', 'relink-btn',
'import-all-btn', 'export-conform-btn', 'fetch-relink-btn'
'import-proxy-btn', 'import-hires-btn', 'mount-live-btn', 'relink-btn', 'upload-mam-btn'
];
function enableDivDisabled() {
ICON_CONTROLS.forEach(id => {
@ -164,24 +163,8 @@
finally { _disableImportBtns(false); Library._syncActions(); }
});
$('import-all-btn').addEventListener('click', async () => {
const assets = Library.state.assets;
if (!assets.length) { UI.toast('No assets', 'error'); return; }
_disableImportBtns(true);
let ok = 0, fail = 0;
for (const a of assets) {
try {
const { localPath, safeName } = await Import.proxy(a);
Library.recordImport(localPath, { assetId: a.id, displayName: a.display_name || a.filename });
Library.recordImport('name:' + safeName, { assetId: a.id, displayName: a.display_name || a.filename });
ok++;
} catch (_) { fail++; }
}
_disableImportBtns(false);
UI.hideProgress();
UI.toast('Import all: ' + ok + ' ok' + (fail ? ', ' + fail + ' failed' : ''), fail ? 'error' : 'ok');
Library._syncActions();
});
// ── Upload highlighted bin file(s) to the MAM ──
$('upload-mam-btn').addEventListener('click', uploadToMam);
$('mount-live-btn').addEventListener('click', async () => {
const a = Library.selectedAsset(); if (!a) return;
@ -231,12 +214,8 @@
finally { Library._syncActions(); }
});
// v2.2.1: Export Timeline is now a single-click pipeline —
// push to MAM → start conform → poll → new asset lands in Library.
// The Conform slide panel is still wired for Advanced → Export & Conform.
$('export-timeline-btn').addEventListener('click', oneClickExport);
$('export-conform-btn').addEventListener('click', openConformPanel);
$('fetch-relink-btn').addEventListener('click', openRelinkPanel);
// Single Export entry → popup menu (Conform Timeline / Local Export).
wireExportMenu();
// Advanced collapsible toggle (v2.2.0).
const advToggle = $('advanced-toggle');
@ -253,6 +232,107 @@
['import-proxy-btn','import-hires-btn'].forEach(id => { $(id).disabled = dis; });
}
function _basename(p) { return String(p).split(/[\\/]/).pop(); }
// Target project for uploads/auto-upload: the project filter when specific,
// else the only project if there's exactly one, else null (caller prompts).
function getTargetProjectId() {
const sel = Library.state.selectedProject;
if (sel && sel !== 'all') return sel;
const projs = Library.state.projects || [];
return projs.length === 1 ? projs[0].id : null;
}
// ── Export menu (single Export button → Conform / Local Export) ───
function _positionFloatMenu(menu, anchorEl) {
menu.classList.remove('hidden');
menu.style.visibility = 'hidden';
const r = anchorEl.getBoundingClientRect();
const m = menu.getBoundingClientRect();
const sx = window.pageXOffset || 0, sy = window.pageYOffset || 0;
const vw = window.innerWidth || document.documentElement.clientWidth || 99999;
const vh = window.innerHeight || document.documentElement.clientHeight || 99999;
let x = r.right + 6; // rail is on the left → open to the right
let y = r.top;
if (x + m.width > vw - 4) x = r.left - m.width - 6; // flip if no room
x = Math.max(4, Math.min(x, vw - m.width - 4));
y = Math.max(4, Math.min(y, vh - m.height - 4));
menu.style.left = (x + sx) + 'px';
menu.style.top = (y + sy) + 'px';
menu.style.visibility = 'visible';
}
function wireExportMenu() {
const btn = $('export-timeline-btn');
const menu = $('export-menu');
if (!btn || !menu) return;
if (menu.parentNode !== document.body) document.body.appendChild(menu);
let open = false;
const close = () => { open = false; menu.classList.add('hidden'); };
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (open) { close(); return; }
open = true; _positionFloatMenu(menu, btn);
});
document.addEventListener('click', (e) => {
if (open && !menu.contains(e.target) && e.target !== btn && !btn.contains(e.target)) close();
});
$('menu-conform').addEventListener('click', () => { close(); openConformPanel(); });
$('menu-local-export').addEventListener('click', () => { close(); runLocalExport(); });
}
// ── Upload highlighted bin file(s) (or file-picker fallback) ─────
async function uploadToMam() {
const projectId = getTargetProjectId();
if (!projectId) { UI.toast('Pick a target project (project filter) before uploading', 'error'); return; }
let paths = [];
try { paths = await Import.getSelectedBinPaths(); } catch (_) {}
if (!paths.length) {
UI.toast('No bin selection — choose file(s) to upload', 'muted');
try { paths = await Import.pickFiles(); }
catch (e) { UI.toast('File picker unavailable: ' + e.message, 'error'); return; }
}
if (!paths.length) return;
let ok = 0, fail = 0;
for (let i = 0; i < paths.length; i++) {
const name = _basename(paths[i]);
UI.showProgress('Uploading ' + name + ' (' + (i + 1) + '/' + paths.length + ')…', 10 + (i / paths.length) * 80);
try { await Import.uploadFile(paths[i], { projectId }); ok++; }
catch (e) { fail++; console.warn('[df] upload failed', paths[i], e.message); }
}
UI.hideProgress();
UI.toast('Uploaded ' + ok + (fail ? ', ' + fail + ' failed' : '') + ' to MAM', fail ? 'error' : 'ok');
if (ok) Library.refresh(Library.state.searchQuery);
}
// ── Local Export (server FFMPEG-trims hi-res → download → relink) ─
async function runLocalExport() {
const projectId = getTargetProjectId();
UI.showProgress('Reading Premiere sequence…', 8);
let td;
try { td = await Timeline.readActiveSequence(); }
catch (e) { UI.hideProgress(); UI.toast('Timeline read failed: ' + e.message, 'error'); return; }
if (!td.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
let resolved = Library.resolveClipsToAssets(td.clips);
const missing = resolved.filter(c => !c.asset_id && c.filePath);
if (missing.length) {
if (!projectId) { UI.hideProgress(); UI.toast(missing.length + ' clip(s) not in MAM — pick a target project so they can be uploaded', 'error'); return; }
try {
await Import.ensureClipsInMam(resolved, projectId, (name, n, total) =>
UI.showProgress('Uploading missing source ' + name + ' (' + n + '/' + total + ')…', 8 + (n / total) * 20));
resolved = Library.resolveClipsToAssets(td.clips);
} catch (e) { UI.hideProgress(); UI.toast('Auto-upload failed: ' + e.message, 'error'); return; }
}
try {
const res = await Timeline.localExport(resolved, (label, pct) => UI.showProgress(label, pct));
UI.hideProgress();
if (res.failed) UI.toast('Local Export: ' + res.succeeded + ' ok, ' + res.failed + ' failed', 'error');
else UI.toast('Local Export complete — ' + res.succeeded + ' clip(s) relinked', 'ok');
} catch (e) { UI.hideProgress(); UI.toast('Local Export failed: ' + e.message, 'error'); }
}
let _seqCache = null;
// ── One-click Export Timeline ────────────────────────────────────
@ -381,9 +461,13 @@
if (!_seqCache.clips.length) { UI.hideProgress(); UI.toast('No clips in active sequence', 'error'); return; }
UI.hideProgress();
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
const total = _seqCache.clips.length;
const matched = resolved.filter(c => c.asset_id).length;
$('conform-clip-info').textContent = matched + ' of ' + _seqCache.clips.length + ' clip(s) matched';
$('conform-start-btn').disabled = matched === 0;
const missing = total - matched;
$('conform-clip-info').textContent = missing
? matched + ' of ' + total + ' clip(s) in MAM — ' + missing + ' will be uploaded first'
: matched + ' of ' + total + ' clip(s) in MAM';
$('conform-start-btn').disabled = total === 0;
const conformProj = $('conform-proj-select');
if (conformProj) {
conformProj.innerHTML = '<option value="">— Select project —</option>';
@ -419,6 +503,15 @@
const projectId = conformProj ? conformProj.value : '';
if (!projectId) { UI.toast('Select a target project', 'error'); return; }
UI.closeSlide('conform-overlay', 'conform-panel');
// Auto-upload any timeline sources not yet in the MAM, then conform.
try {
const resolved = Library.resolveClipsToAssets(_seqCache.clips);
const missing = resolved.filter(c => !c.asset_id && c.filePath);
if (missing.length) {
await Import.ensureClipsInMam(resolved, projectId, (name, n, tot) =>
UI.showProgress('Uploading missing source ' + name + ' (' + n + '/' + tot + ')…', 5 + (n / tot) * 10));
}
} catch (e) { UI.hideProgress(); UI.toast('Auto-upload failed: ' + e.message, 'error'); return; }
UI.showProgress('Starting conform job…', 15);
try {
const jobId = await Timeline.startConform(projectId, _seqCache.sequenceName, _seqCache, {

View file

@ -304,5 +304,76 @@
return count;
};
// ── Local Export ─────────────────────────────────────────────────
// Server trims each timeline clip's hi-res via FFMPEG, then we download
// the trimmed segments and relink the project items to them.
// CAVEAT: relink keys on the source media path, so a source used by
// multiple timeline clips with different in/out points will relink to a
// single segment (last one wins). Common single-use case is exact.
Timeline.localExport = async function (resolvedClips, onProgress) {
const P = ppro();
const project = await P.Project.getActiveProject();
if (!project) throw new Error('No active Premiere project');
const matched = (resolvedClips || []).filter(c => c.asset_id);
if (!matched.length) throw new Error('No clips matched MAM assets to export');
const payload = matched.map(c => ({
assetId: c.asset_id,
filename: c.fileName || (c.filePath ? path.basename(c.filePath) : 'clip'),
sourceInFrames: c.sourceInFrames, sourceOutFrames: c.sourceOutFrames,
timelineInFrames: c.timelineInFrames, timelineOutFrames: c.timelineOutFrames,
trackIndex: c.trackIndex,
}));
onProgress && onProgress('Requesting trim of ' + matched.length + ' clip(s)…', 10);
const job = await API.batchTrim(payload);
const jobId = job.jobId;
const clipByInstance = {};
(job.clips || []).forEach((cr, i) => { if (cr.clipInstanceId) clipByInstance[cr.clipInstanceId] = matched[i]; });
// Poll until every segment is ready (s3Key set) or the job fails.
const ready = {};
await new Promise((resolve, reject) => {
const t = setInterval(async () => {
try {
const st = await API.getTrimStatus(jobId);
const clips = st.clips || [];
const completed = clips.filter(c => c.status === 'completed' && c.s3Key);
onProgress && onProgress('Trimming on server… (' + completed.length + '/' + clips.length + ')',
15 + (completed.length / Math.max(1, clips.length)) * 45);
if (st.status === 'failed') { clearInterval(t); reject(new Error('Server trim job failed')); return; }
if (clips.length && completed.length === clips.length) {
clearInterval(t); completed.forEach(c => { ready[c.clipInstanceId] = c; }); resolve();
}
} catch (_) { /* transient — keep polling */ }
}, 2000);
});
// Download each segment and relink the source media path to it.
const results = { succeeded: 0, failed: 0, errors: [] };
const ids = Object.keys(ready);
for (let i = 0; i < ids.length; i++) {
const cid = ids[i];
const clip = clipByInstance[cid];
if (!clip) continue;
try {
onProgress && onProgress('Downloading segment ' + (i + 1) + '/' + ids.length + '…', 60 + (i / ids.length) * 35);
const seg = await API.getTempSegmentUrl(cid);
const ext = (seg.s3Key && seg.s3Key.split('.').pop()) || 'mov';
const base = UI.sanitizeFilename((clip.fileName || 'clip') + '-trim-' + cid.slice(0, 8) + '.' + ext);
const dest = await Import._tempPath(base);
const r = await API.requestExternal(seg.url);
if (!r.ok) throw new Error('Segment download HTTP ' + r.status);
await Import._writeBuffer(dest, await r.arrayBuffer());
if (clip.filePath) await Timeline._relinkInProject(project, clip.filePath, dest);
results.succeeded++;
} catch (e) {
results.failed++;
results.errors.push((clip && clip.fileName || 'clip') + ': ' + e.message);
}
}
return results;
};
window.Timeline = Timeline;
})();

View file

@ -227,6 +227,9 @@ input[type="search"]::-webkit-search-cancel-button { display: none; }
padding: 4px;
z-index: 50;
}
/* Floating variant: coordinates are set in JS (rail Export menu), so the
default top/right anchoring is cleared and it sits above everything. */
.menu--float { top: auto; right: auto; min-width: 184px; z-index: 1000; }
.menu-item {
display: block;
width: 100%;