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:
parent
7e9f1277d4
commit
3f203f326e
7 changed files with 364 additions and 42 deletions
|
|
@ -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 & 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 & 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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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 }; }
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
Loading…
Reference in a new issue