dragonflight/services/premiere-plugin-uxp/src/import-flow.js
zgaetano 39ef551489 feat(uxp): ship the icon-rail panel redesign as v2.2.2 (recover from redesign branch)
The redesigned UXP panel (left icon rail, compact list-view toggle, hover
tooltips, single Export menu) was committed only to redesign/panel-icon-rail
and never merged, so main + the website kept serving the old blocky-button
build under the same version number (2.2.2). That branch had diverged off an
old main and is missing recent worker/HLS/NVENC/import work, so it can't be
merged wholesale — cherry-pick just the plugin instead.

- services/premiere-plugin-uxp: replace source with the redesigned panel
  (adds src/tooltip.js; reworks index.html + styles.css + src/*). Verified
  byte-identical to the build installed on BMG-PC-Edit.
- web-ui/public/downloads/dragonflight-mam-2.2.2.ccx: swap the served
  artifact to the redesigned 34708-byte build (download link unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 20:45:29 -04:00

264 lines
11 KiB
JavaScript

// import-flow.js — v2.1.6
// premierepro API: docs say sync, runtime returns Promises. Await everything.
(function () {
const Import = {};
const fs = require('fs');
// window.path is a UXP global (v6.4+) — no require('path')
let os; try { os = require('os'); } catch (_) { os = {}; }
let uxpFs; try { uxpFs = require('uxp').storage.localFileSystem; } catch (_) { uxpFs = null; }
// ── Temp folder ──────────────────────────────────────────────────
async function _getTempBase() {
if (uxpFs && uxpFs.getTemporaryFolder) {
try {
const tmp = await uxpFs.getTemporaryFolder();
if (tmp && tmp.nativePath) return tmp.nativePath;
} catch (_) {}
}
try {
const e = (typeof process !== 'undefined' && process.env) || {};
if (e.TEMP && e.TEMP.length) return e.TEMP;
if (e.TMP && e.TMP.length) return e.TMP;
if (e.LOCALAPPDATA) return e.LOCALAPPDATA + '\\Temp';
} catch (_) {}
try {
if (os.homedir) {
const h = os.homedir();
if (h) return h + '\\AppData\\Local\\Temp';
}
} catch (_) {}
throw new Error('Cannot find writable temp folder');
}
Import._tempPath = async function (safeName) {
const base = await _getTempBase();
return path.join(base, 'dragonflight-' + safeName);
};
// Returns true if the path already exists on disk.
Import._fileExists = async function (filePath) {
try { await fs.stat(filePath); return true; } catch (_) { return false; }
};
// Write ArrayBuffer to disk via fs.writeFile.
// If the file is locked (EBUSY — Premiere already has it open from a
// previous import) we treat that as success: the bytes are already there.
Import._writeBuffer = async function (destPath, arrayBuffer) {
try {
await fs.writeFile(destPath, arrayBuffer);
} catch (e) {
const busy = e.code === 'EBUSY' || /resource busy/i.test(String(e.message));
if (!busy) throw e;
// File locked by Premiere — it's already there, proceed.
console.warn('[df] _writeBuffer EBUSY on', destPath, '— using existing file');
}
return destPath;
};
// Fetch — no redirect option (not supported in UXP)
// addAuth: only for same-origin proxy URLs, NOT for S3 presigned URLs
async function _fetch(url, addAuth) {
const headers = {};
if (addAuth && API.state.apiToken) {
headers['Authorization'] = 'Bearer ' + API.state.apiToken;
}
const r = await fetch(url, { headers });
if (!r.ok) throw new Error('HTTP ' + r.status + ' fetching ' + url);
return r;
}
// ── premierepro lazy require ─────────────────────────────────────
function _ppro() {
if (Import._ppro_mod) return Import._ppro_mod;
try { Import._ppro_mod = require('premierepro'); }
catch (e) { throw new Error('UXP premierepro unavailable: ' + e.message); }
return Import._ppro_mod;
}
// Import a file into the active Premiere project.
// ALL premierepro calls must be awaited — runtime returns Promises
// even though docs list them as synchronous.
// importFiles returns false when the file is already in the project — not an error.
Import.importIntoProject = async function (filePath) {
const P = _ppro();
const project = await P.Project.getActiveProject();
if (!project) throw new Error('No active Premiere project');
const root = await project.getRootItem();
await project.importFiles([filePath], true, root, false);
// Return value false = already in project. Either way, we're done.
return true;
};
// ── Proxy import ─────────────────────────────────────────────────
Import.proxy = async function (asset) {
const safeName = UI.sanitizeFilename((asset.display_name || asset.filename || asset.id) + '.mp4');
const dest = await Import._tempPath(safeName);
const alreadyOnDisk = await Import._fileExists(dest);
if (alreadyOnDisk) {
// File exists from a previous import — skip download, just (re-)import.
UI.showProgress('Importing into Premiere…', 92);
} else {
UI.showProgress('Resolving proxy URL…', 4);
const { url } = await API.getProxyUrl(asset.id);
UI.showProgress('Downloading ' + safeName + '…', 10);
const r = await _fetch(url, true); // same-origin — add auth
UI.showProgress('Writing to disk…', 70);
const buf = await r.arrayBuffer();
await Import._writeBuffer(dest, buf);
UI.showProgress('Importing into Premiere…', 92);
}
await Import.importIntoProject(dest);
UI.hideProgress();
UI.toast('Imported: ' + safeName, 'ok');
return { localPath: dest, safeName };
};
// ── Hi-Res import ────────────────────────────────────────────────
Import.hires = async function (asset) {
UI.showProgress('Resolving hi-res URL…', 4);
const info = await API.getHiresInfo(asset.id);
const safeName = UI.sanitizeFilename(info.filename || (asset.display_name || asset.id) + '.' + (info.ext || 'mxf'));
const dest = await Import._tempPath(safeName);
const alreadyOnDisk = await Import._fileExists(dest);
if (alreadyOnDisk) {
UI.showProgress('Importing into Premiere…', 92);
} else {
UI.showProgress('Downloading ' + safeName + ' (' + UI.formatBytes(Number(info.file_size || 0)) + ')…', 8);
const r = await _fetch(info.url, false); // presigned S3 — no auth
UI.showProgress('Writing to disk…', 75);
const buf = await r.arrayBuffer();
await Import._writeBuffer(dest, buf);
UI.showProgress('Importing into Premiere…', 92);
}
await Import.importIntoProject(dest);
UI.hideProgress();
UI.toast('Hi-res imported: ' + safeName, 'ok');
return { localPath: dest, safeName };
};
// Used by timeline.js batch relink
Import._streamToFile = async function (response, destPath /*, onProgress */) {
const buf = await response.arrayBuffer();
await Import._writeBuffer(destPath, buf);
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;
})();