diff --git a/services/premiere-plugin-uxp/README.md b/services/premiere-plugin-uxp/README.md
new file mode 100644
index 0000000..38be8b2
--- /dev/null
+++ b/services/premiere-plugin-uxp/README.md
@@ -0,0 +1,89 @@
+# Dragonflight MAM — Premiere Pro UXP panel (v2.0.0)
+
+The successor to the CEP panel at `services/premiere-plugin/`.
+
+**Why this exists:** Premiere Pro 26.0.x broke `csInterface.evalScript`'s
+return-value callback — it no longer fires. That deadlocks any CEP panel
+that needs to call back into Premiere (importFiles, sequence access, etc).
+Adobe's path forward is UXP; this panel is the minimum viable port that
+restores the Import Proxy / Import Hi-Res workflow.
+
+## Scope (v2.0.0)
+
+In:
+- Connect to a Dragonflight server (URL + Bearer token, persisted in localStorage)
+- Browse the asset library (search, refresh, grid with thumbnails)
+- **Import Proxy** — downloads the proxy MP4 and adds it to the active project
+- **Import Hi-Res** — downloads the original from S3 (presigned URL) and imports
+
+Out (carried over from the CEP panel later, as v2.x):
+- Mount Live (SMB live preview)
+- Conform / Auto-Relink / Batch Trim
+- Timeline export
+- Settings tabs (Account, Tokens, S3) — use the web UI for now
+
+## Project layout
+
+```
+manifest.json UXP manifest v5 (host=premierepro, minVersion=26.0.0)
+index.html Panel shell (vanilla HTML)
+styles.css Mirrors the web UI's dark design tokens
+src/
+ ui.js DOM helpers, toast, progress, formatting
+ api.js Dragonflight HTTP client (Bearer auth, redirect handling)
+ library.js Asset grid render + selection
+ import-flow.js Download (streamed) + Premiere importFiles
+ main.js Bootstrap, event wiring
+icons/ DarkIcon.png + LightIcon.png (23x23, optional)
+build/pack.mjs Pack into .ccx for install
+```
+
+## Build
+
+Requires Node 18+.
+
+```bash
+cd services/premiere-plugin-uxp
+node build/pack.mjs
+# → build/dist/dragonflight-mam-2.0.0.ccx
+```
+
+(Uses system `zip`. On Windows hosts use Compress-Archive — error message
+shows the exact command.)
+
+## Install on Windows
+
+Prerequisite: **Premiere Pro Preferences > Plugins > Enable developer mode**
+must be toggled on (one-time, per machine). This is required for any UXP
+plugin not distributed via Creative Cloud Marketplace.
+
+```powershell
+& "C:\Program Files\Common Files\Adobe\Adobe Desktop Common\RemoteComponents\UPI\UnifiedPluginInstallerAgent\UnifiedPluginInstallerAgent.exe" /install "C:\path\to\dragonflight-mam-2.0.0.ccx"
+```
+
+That writes to `%APPDATA%\Adobe\UXP\Plugins\External\net.wilddragon.dragonflight.uxp\` and registers the plugin with UPIA.
+
+To uninstall:
+```powershell
+& "...\UnifiedPluginInstallerAgent.exe" /remove net.wilddragon.dragonflight.uxp
+```
+
+Open the panel in Premiere via **Window > Extensions > Dragonflight MAM**.
+
+## Development loop (Adobe UXP Developer Tool)
+
+For iterative dev without rebuilding+reinstalling:
+1. Install **Adobe UXP Developer Tool** ("UDT") from Creative Cloud.
+2. UDT → Add Plugin → point at this folder.
+3. Load → opens the panel in Premiere with hot-reload.
+
+## Known limits
+
+- Adobe strips `Authorization` headers on cross-origin redirects. `import-flow.js`
+ uses `redirect: 'manual'` and drops the Bearer when hopping to a different
+ host (e.g. when the server 302s us to the S3 endpoint).
+- `Project.importFiles()` returns a `boolean`, not the new `ProjectItem`. To
+ locate the imported item, `Import.locateImported(project, filename)` does a
+ rootItem.getItems() diff after import.
+- UXP's `os.tmpdir()` resolves to Windows `%TEMP%` which is cleared by Disk
+ Cleanup. For long-lived downloads, persist explicitly elsewhere.
diff --git a/services/premiere-plugin-uxp/index.html b/services/premiere-plugin-uxp/index.html
new file mode 100644
index 0000000..eb1309a
--- /dev/null
+++ b/services/premiere-plugin-uxp/index.html
@@ -0,0 +1,62 @@
+
+
+
+
+ Dragonflight MAM
+
+
+
+
+
+
+
+
Dragonflight
+
Wild Dragon Broadcast
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading…
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/services/premiere-plugin-uxp/manifest.json b/services/premiere-plugin-uxp/manifest.json
new file mode 100644
index 0000000..a749736
--- /dev/null
+++ b/services/premiere-plugin-uxp/manifest.json
@@ -0,0 +1,31 @@
+{
+ "manifestVersion": 5,
+ "id": "net.wilddragon.dragonflight.uxp",
+ "name": "Dragonflight MAM",
+ "version": "2.0.0",
+ "main": "index.html",
+ "host": {
+ "app": "premierepro",
+ "minVersion": "26.0.0"
+ },
+ "entrypoints": [
+ {
+ "type": "panel",
+ "id": "main",
+ "label": { "default": "Dragonflight MAM" },
+ "minimumSize": { "width": 320, "height": 360 },
+ "preferredDockedSize": { "width": 380, "height": 600 },
+ "preferredFloatingSize":{ "width": 420, "height": 720 }
+ }
+ ],
+ "requiredPermissions": {
+ "localFileSystem": "fullAccess",
+ "network": {
+ "domains": [
+ "https://dragonflight.live",
+ "https://broadcastmgmt.cloud"
+ ]
+ },
+ "allowCodeGenerationFromStrings": false
+ }
+}
diff --git a/services/premiere-plugin-uxp/src/api.js b/services/premiere-plugin-uxp/src/api.js
new file mode 100644
index 0000000..61d22b2
--- /dev/null
+++ b/services/premiere-plugin-uxp/src/api.js
@@ -0,0 +1,104 @@
+// Dragonflight API client. Wraps UXP's fetch() with the Bearer header and
+// handles the cross-origin-redirect quirk (UXP strips Authorization across
+// hosts as a security fix — we follow such redirects manually).
+//
+// Persists serverUrl + apiToken in localStorage so the panel reconnects on
+// reopen.
+
+(function () {
+ const API = {};
+ const LS_URL = 'df.uxp.serverUrl';
+ const LS_TOKEN = 'df.uxp.apiToken';
+
+ API.state = {
+ serverUrl: localStorage.getItem(LS_URL) || '',
+ apiToken: localStorage.getItem(LS_TOKEN) || '',
+ connected: false,
+ };
+
+ API.save = function () {
+ localStorage.setItem(LS_URL, API.state.serverUrl);
+ localStorage.setItem(LS_TOKEN, API.state.apiToken);
+ };
+
+ API.clear = function () {
+ API.state.serverUrl = '';
+ API.state.apiToken = '';
+ API.state.connected = false;
+ localStorage.removeItem(LS_URL);
+ localStorage.removeItem(LS_TOKEN);
+ };
+
+ function trimUrl(u) { return String(u || '').replace(/\/+$/, ''); }
+
+ // Core request. `url` may be a path (joined to serverUrl) or absolute.
+ // Auth header is added only for requests to our own serverUrl.
+ API.request = async function (urlOrPath, opts) {
+ opts = opts || {};
+ const isAbs = /^https?:\/\//i.test(urlOrPath);
+ const base = trimUrl(API.state.serverUrl);
+ const url = isAbs ? urlOrPath : (base + urlOrPath);
+ const headers = Object.assign({}, opts.headers || {});
+ if (!isAbs || url.indexOf(base) === 0) {
+ if (API.state.apiToken) headers['Authorization'] = 'Bearer ' + API.state.apiToken;
+ }
+ // UXP fetch supports standard options. Don't pass `credentials` — UXP
+ // doesn't ship a cookie jar and warns about unsupported values.
+ return fetch(url, Object.assign({}, opts, { headers }));
+ };
+
+ API.json = async function (path, opts) {
+ const r = await API.request(path, opts);
+ if (!r.ok) {
+ const text = await r.text().catch(() => '');
+ throw new Error('HTTP ' + r.status + (text ? ' — ' + text.slice(0, 200) : ''));
+ }
+ return r.json();
+ };
+
+ // GET /api/v1/auth/me — used as the connect probe. Returns 200 on a valid
+ // bearer, 401 otherwise.
+ API.connect = async function (serverUrl, apiToken) {
+ API.state.serverUrl = trimUrl(serverUrl);
+ API.state.apiToken = String(apiToken || '').trim();
+ if (!API.state.serverUrl || !API.state.apiToken) {
+ throw new Error('Server URL and API token are required');
+ }
+ const me = await API.json('/api/v1/auth/me');
+ API.state.connected = true;
+ API.save();
+ return me;
+ };
+
+ API.disconnect = function () {
+ API.clear();
+ };
+
+ // Asset list. The web UI calls /api/v1/assets?... — we mirror that.
+ API.listAssets = async function (query) {
+ const params = new URLSearchParams();
+ if (query) params.set('q', query);
+ params.set('limit', '60');
+ return API.json('/api/v1/assets?' + params.toString());
+ };
+
+ // Resolve a proxy stream URL → returns an absolute URL we can fetch.
+ // /stream returns { url: '/api/v1/assets//video', type: 'mp4', source }.
+ API.getProxyUrl = async function (assetId) {
+ const data = await API.json('/api/v1/assets/' + assetId + '/stream');
+ if (!data || !data.url) throw new Error('Asset has no proxy');
+ const u = data.url;
+ const abs = /^https?:\/\//i.test(u) ? u : trimUrl(API.state.serverUrl) + u;
+ return { url: abs, source: data.source || 'proxy' };
+ };
+
+ // Resolve a hi-res URL → returns a presigned S3 URL (off-host).
+ // /hires returns { url, filename, ext, file_size, type: 'hires' }.
+ API.getHiresInfo = async function (assetId) {
+ const data = await API.json('/api/v1/assets/' + assetId + '/hires');
+ if (!data || !data.url) throw new Error('Asset has no hi-res source');
+ return data;
+ };
+
+ window.API = API;
+})();
diff --git a/services/premiere-plugin-uxp/src/import-flow.js b/services/premiere-plugin-uxp/src/import-flow.js
new file mode 100644
index 0000000..95163bc
--- /dev/null
+++ b/services/premiere-plugin-uxp/src/import-flow.js
@@ -0,0 +1,162 @@
+// Download asset → import into the active Premiere project via UXP's
+// `premierepro` module. This is what the CEP panel could no longer do
+// (csInterface.evalScript callback was lost in PPro 26).
+
+(function () {
+ const Import = {};
+
+ // Use UXP's Node-style fs for streaming writes (avoids buffering multi-GB
+ // files in memory) and the `os` module for the temp dir. Both are exposed
+ // when manifest declares "localFileSystem": "fullAccess".
+ const fs = require('fs');
+ const os = require('os');
+ const path = require('path');
+
+ // Pick a temp folder dragonflight downloads land in. Cleared on each
+ // download to keep disk usage bounded.
+ function tempPath(safeName) {
+ return path.join(os.tmpdir(), 'dragonflight-' + safeName);
+ }
+
+ // Stream the body of a fetch Response to a file on disk.
+ // Returns the absolute path. Reports progress via onProgress({ received, total }).
+ async function streamToFile(response, destPath, onProgress) {
+ const total = Number(response.headers.get('content-length') || 0);
+ const reader = response.body.getReader();
+ const out = fs.createWriteStream(destPath);
+ let received = 0;
+ // Wrap close in a Promise so we don't resolve before the OS finishes
+ // flushing — without this, importFiles can race the writer.
+ const closed = new Promise((resolve, reject) => {
+ out.on('finish', resolve);
+ out.on('error', reject);
+ });
+ try {
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ if (!out.write(Buffer.from(value))) {
+ // Backpressure — wait for drain so memory stays flat.
+ await new Promise(r => out.once('drain', r));
+ }
+ received += value.byteLength;
+ if (onProgress) onProgress({ received, total });
+ }
+ } finally {
+ out.end();
+ }
+ await closed;
+ return destPath;
+ }
+
+ // Manual-follow fetch for redirects: UXP strips Authorization across
+ // origins (per Adobe security policy), so for any /api/... call that might
+ // 302 to S3 we follow the redirect ourselves and DROP the Bearer header
+ // on the next hop.
+ async function fetchFollow(url, opts) {
+ opts = opts || {};
+ let current = url;
+ let auth = (opts.headers || {})['Authorization'] || null;
+ for (let hop = 0; hop < 5; hop++) {
+ const r = await fetch(current, Object.assign({}, opts, { redirect: 'manual' }));
+ if (r.status >= 300 && r.status < 400 && r.headers.get('location')) {
+ const next = r.headers.get('location');
+ const absNext = /^https?:\/\//i.test(next) ? next : new URL(next, current).toString();
+ const sameHost = (new URL(absNext)).host === (new URL(current)).host;
+ opts = Object.assign({}, opts, {
+ headers: Object.assign({}, opts.headers || {}, sameHost ? {} : { Authorization: undefined }),
+ });
+ if (!sameHost) delete opts.headers.Authorization;
+ current = absNext;
+ continue;
+ }
+ return r;
+ }
+ throw new Error('Too many redirects');
+ }
+
+ // Find the new ProjectItem after importFiles. Premiere's UXP importFiles
+ // returns only a boolean — we diff the rootItem.getItems() snapshot.
+ // (Not currently used but available for callers that need the item.)
+ Import.locateImported = async function (project, filename) {
+ try {
+ const root = await project.getRootItem();
+ const items = await root.getItems();
+ for (const it of items) {
+ const name = await it.getName().catch(() => null);
+ if (name && name === filename) return it;
+ }
+ } catch (_) {}
+ return null;
+ };
+
+ // Talk to Premiere. Lazy-require so the module isn't loaded until the user
+ // actually triggers an import (faster panel boot, clearer error if UXP host
+ // is missing the API).
+ function ppro() {
+ if (Import._ppro) return Import._ppro;
+ try { Import._ppro = require('premierepro'); }
+ catch (e) { throw new Error('UXP premierepro module unavailable: ' + e.message); }
+ return Import._ppro;
+ }
+
+ // Hand a downloaded file off to Premiere.
+ Import.importIntoProject = async function (filePath) {
+ const P = ppro();
+ const project = await P.Project.getActiveProject();
+ if (!project) throw new Error('No active Premiere project. Open or create a project first.');
+ const root = await project.getRootItem();
+ // suppressUI=true, targetBin=root, asNumberedStills=false.
+ const ok = await project.importFiles([filePath], true, root, false);
+ if (!ok) throw new Error('Premiere refused to import the file.');
+ return true;
+ };
+
+ // ── Proxy import: /stream returns a same-host /video URL ──────────
+ Import.proxy = async function (asset) {
+ const safeName = UI.sanitizeFilename((asset.display_name || asset.filename || asset.id) + '.mp4');
+ const dest = tempPath(safeName);
+
+ UI.showProgress('Resolving proxy URL…', 4);
+ const { url } = await API.getProxyUrl(asset.id);
+
+ UI.showProgress('Downloading ' + safeName + '…', 10);
+ const r = await fetchFollow(url, { headers: { Authorization: 'Bearer ' + API.state.apiToken } });
+ if (!r.ok) throw new Error('Download HTTP ' + r.status);
+
+ await streamToFile(r, dest, ({ received, total }) => {
+ const pct = total ? 10 + (received / total) * 75 : 10;
+ UI.showProgress('Downloading ' + UI.formatBytes(received) + (total ? ' / ' + UI.formatBytes(total) : '') + '…', pct);
+ });
+
+ UI.showProgress('Importing into Premiere…', 92);
+ await Import.importIntoProject(dest);
+ UI.hideProgress();
+ UI.toast('Imported: ' + safeName, 'ok');
+ };
+
+ // ── Hi-Res import: /hires returns a presigned S3 URL on broadcastmgmt.cloud
+ 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 = tempPath(safeName);
+
+ UI.showProgress('Downloading ' + safeName + ' (' + UI.formatBytes(Number(info.file_size || 0)) + ')…', 8);
+ // info.url is presigned S3 — DO NOT add Bearer (would break signature).
+ const r = await fetchFollow(info.url, {});
+ if (!r.ok) throw new Error('Download HTTP ' + r.status);
+
+ await streamToFile(r, dest, ({ received, total }) => {
+ const pct = total ? 8 + (received / total) * 80 : 8;
+ UI.showProgress('Downloading ' + UI.formatBytes(received) + (total ? ' / ' + UI.formatBytes(total) : '') + '…', pct);
+ });
+
+ UI.showProgress('Importing into Premiere…', 92);
+ await Import.importIntoProject(dest);
+ UI.hideProgress();
+ UI.toast('Hi-res imported: ' + safeName, 'ok');
+ };
+
+ window.Import = Import;
+})();
diff --git a/services/premiere-plugin-uxp/src/library.js b/services/premiere-plugin-uxp/src/library.js
new file mode 100644
index 0000000..e1d618b
--- /dev/null
+++ b/services/premiere-plugin-uxp/src/library.js
@@ -0,0 +1,105 @@
+// Asset library rendering. Calls API.listAssets, renders a grid into
+// #asset-grid, tracks the selected asset for the action buttons.
+
+(function () {
+ const Library = {};
+ Library.state = { assets: [], selectedId: null };
+
+ Library.render = function () {
+ const grid = UI.$('#asset-grid');
+ grid.innerHTML = '';
+ if (!Library.state.assets.length) {
+ const e = document.createElement('div');
+ e.className = 'empty muted';
+ e.textContent = 'No assets';
+ grid.appendChild(e);
+ return;
+ }
+ for (const a of Library.state.assets) {
+ grid.appendChild(makeCard(a));
+ }
+ Library.syncActions();
+ };
+
+ function makeCard(asset) {
+ const card = document.createElement('div');
+ card.className = 'asset-card';
+ if (asset.id === Library.state.selectedId) card.classList.add('selected');
+ card.dataset.assetId = asset.id;
+
+ const thumbKey = asset.thumbnail_s3_key || asset.thumbnail || null;
+ const thumbUrl = thumbKey
+ ? `${API.state.serverUrl}/api/v1/assets/${asset.id}/thumbnail?redirect=1`
+ : null;
+
+ if (thumbUrl) {
+ const img = document.createElement('img');
+ img.className = 'asset-thumb';
+ img.alt = asset.display_name || asset.filename || asset.id;
+ // UXP fetch supports cross-origin images; the redirect=1 query on
+ // /thumbnail tells the server to 302 to the presigned S3 URL.
+ img.src = thumbUrl;
+ img.onerror = () => { img.replaceWith(placeholder()); };
+ card.appendChild(img);
+ } else {
+ card.appendChild(placeholder());
+ }
+
+ const name = document.createElement('div');
+ name.className = 'asset-name';
+ name.textContent = asset.display_name || asset.filename || asset.id;
+ card.appendChild(name);
+
+ card.addEventListener('click', () => Library.select(asset.id));
+ return card;
+ }
+
+ function placeholder() {
+ const p = document.createElement('div');
+ p.className = 'asset-thumb-placeholder';
+ p.textContent = 'no preview';
+ return p;
+ }
+
+ Library.select = function (id) {
+ Library.state.selectedId = id;
+ Library.render();
+ };
+
+ Library.selectedAsset = function () {
+ return Library.state.assets.find(a => a.id === Library.state.selectedId) || null;
+ };
+
+ Library.syncActions = function () {
+ const sel = Library.selectedAsset();
+ const info = UI.$('#selected-info');
+ if (sel) {
+ info.textContent = (sel.display_name || sel.filename || sel.id)
+ + (sel.file_size ? ' · ' + UI.formatBytes(Number(sel.file_size)) : '');
+ info.classList.remove('muted');
+ } else {
+ info.textContent = 'No asset selected';
+ info.classList.add('muted');
+ }
+ UI.$('#import-proxy-btn').disabled = !sel;
+ UI.$('#import-hires-btn').disabled = !sel || !sel.original_s3_key;
+ };
+
+ Library.refresh = async function (query) {
+ const grid = UI.$('#asset-grid');
+ grid.innerHTML = '