feat(premiere-plugin-uxp): v2.0.0 — UXP port replacing CEP for import
CEP `csInterface.evalScript` callback is broken in Premiere Pro 26.0.x —
nothing called from the panel ever returns, so importFiles deadlocks. Adobe's
path forward is UXP. This is the minimum viable port that restores the
Import Proxy / Import Hi-Res workflow.
Scope (v2.0.0):
- Connect to a Dragonflight server (URL + Bearer token; persisted)
- Asset library (search, refresh, grid with thumbnails)
- Import Proxy via streamed download → Project.importFiles
- Import Hi-Res via presigned S3 URL → Project.importFiles
Layout:
manifest.json UXP v5, host=premierepro, minVersion=26.0.0
index.html Panel shell
styles.css Mirrors web UI dark tokens
src/ui.js DOM helpers, toast, progress, formatting
src/api.js HTTP client (Bearer; manual redirect-follow drops auth
when hopping to a different host per UXP security policy)
src/library.js Asset grid render + selection
src/import-flow.js Streaming download (fs.createWriteStream) +
premierepro.Project.importFiles into rootBin
src/main.js Bootstrap, event wiring
build/pack.mjs Packs into .ccx; installs via UnifiedPluginInstallerAgent
Coexists with services/premiere-plugin/ (CEP) — keeps the CEP panel for any
features that still work there while running v2.0.0 for import. Future v2.x
will add live preview, conform, timeline export, settings.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
8b48f03f6b
commit
91e4691230
9 changed files with 929 additions and 0 deletions
89
services/premiere-plugin-uxp/README.md
Normal file
89
services/premiere-plugin-uxp/README.md
Normal file
|
|
@ -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.
|
||||
62
services/premiere-plugin-uxp/index.html
Normal file
62
services/premiere-plugin-uxp/index.html
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Dragonflight MAM</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<!-- Connection bar (visible when disconnected) -->
|
||||
<section id="connect-pane" class="pane">
|
||||
<div class="brand">
|
||||
<div class="brand-title">Dragonflight</div>
|
||||
<div class="brand-tag">Wild Dragon Broadcast</div>
|
||||
</div>
|
||||
<label class="label" for="server-url">Server URL</label>
|
||||
<input id="server-url" type="text" placeholder="https://dragonflight.live" />
|
||||
<label class="label" for="api-token">API token</label>
|
||||
<input id="api-token" type="password" placeholder="Bearer token from web UI" autocomplete="off" />
|
||||
<button id="connect-btn" class="btn btn-primary" disabled>Connect</button>
|
||||
<div id="connect-status" class="status muted"></div>
|
||||
</section>
|
||||
|
||||
<!-- Connected: library + actions -->
|
||||
<section id="library-pane" class="pane hidden">
|
||||
<header class="connected-bar">
|
||||
<span class="dot dot-ok"></span>
|
||||
<span id="connected-host" class="connected-host"></span>
|
||||
<button id="disconnect-btn" class="btn btn-link">Disconnect</button>
|
||||
</header>
|
||||
|
||||
<div class="library-controls">
|
||||
<input id="search-input" type="search" placeholder="Search assets…" />
|
||||
<button id="refresh-btn" class="btn btn-icon" title="Refresh">↻</button>
|
||||
</div>
|
||||
|
||||
<div id="asset-grid" class="asset-grid">
|
||||
<div class="empty muted">Loading…</div>
|
||||
</div>
|
||||
|
||||
<footer class="actions">
|
||||
<div id="selected-info" class="selected-info muted">No asset selected</div>
|
||||
<div class="action-row">
|
||||
<button id="import-proxy-btn" class="btn btn-primary" disabled>Import Proxy</button>
|
||||
<button id="import-hires-btn" class="btn btn-secondary" disabled>Hi-Res</button>
|
||||
</div>
|
||||
<div id="progress-row" class="progress-row hidden">
|
||||
<div class="progress-bar"><div id="progress-fill"></div></div>
|
||||
<div id="progress-label" class="progress-label">…</div>
|
||||
</div>
|
||||
<div id="toast" class="toast hidden"></div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="src/ui.js"></script>
|
||||
<script src="src/api.js"></script>
|
||||
<script src="src/library.js"></script>
|
||||
<script src="src/import-flow.js"></script>
|
||||
<script src="src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
services/premiere-plugin-uxp/manifest.json
Normal file
31
services/premiere-plugin-uxp/manifest.json
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
104
services/premiere-plugin-uxp/src/api.js
Normal file
104
services/premiere-plugin-uxp/src/api.js
Normal file
|
|
@ -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/<id>/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;
|
||||
})();
|
||||
162
services/premiere-plugin-uxp/src/import-flow.js
Normal file
162
services/premiere-plugin-uxp/src/import-flow.js
Normal file
|
|
@ -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;
|
||||
})();
|
||||
105
services/premiere-plugin-uxp/src/library.js
Normal file
105
services/premiere-plugin-uxp/src/library.js
Normal file
|
|
@ -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 = '<div class="empty muted">Loading…</div>';
|
||||
try {
|
||||
const data = await API.listAssets(query);
|
||||
Library.state.assets = (data && (data.assets || data.rows)) || [];
|
||||
Library.render();
|
||||
} catch (e) {
|
||||
grid.innerHTML = '';
|
||||
const err = document.createElement('div');
|
||||
err.className = 'empty muted';
|
||||
err.textContent = 'Error loading assets: ' + e.message;
|
||||
grid.appendChild(err);
|
||||
}
|
||||
};
|
||||
|
||||
window.Library = Library;
|
||||
})();
|
||||
104
services/premiere-plugin-uxp/src/main.js
Normal file
104
services/premiere-plugin-uxp/src/main.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
// Panel bootstrap. Wires DOM events to API / Library / Import handlers and
|
||||
// restores the connection from localStorage on mount.
|
||||
|
||||
(function () {
|
||||
// Avoid running twice if UXP reloads the panel.
|
||||
if (window.__df_uxp_started) return;
|
||||
window.__df_uxp_started = true;
|
||||
|
||||
function syncConnectBtn() {
|
||||
const u = UI.$('#server-url').value.trim();
|
||||
const t = UI.$('#api-token').value.trim();
|
||||
UI.$('#connect-btn').disabled = !u || !t;
|
||||
}
|
||||
|
||||
async function tryConnect(serverUrl, apiToken) {
|
||||
UI.setStatus('#connect-status', 'Connecting…', 'muted');
|
||||
try {
|
||||
const me = await API.connect(serverUrl, apiToken);
|
||||
UI.$('#connected-host').textContent =
|
||||
(me.user && (me.user.display_name || me.user.username)) ?
|
||||
(me.user.display_name || me.user.username) + ' @ ' + serverUrl
|
||||
: serverUrl;
|
||||
UI.setStatus('#connect-status', '', 'muted');
|
||||
UI.showPane('library');
|
||||
await Library.refresh('');
|
||||
} catch (e) {
|
||||
API.state.connected = false;
|
||||
UI.setStatus('#connect-status', 'Connect failed: ' + e.message, 'error');
|
||||
UI.showPane('connect');
|
||||
}
|
||||
}
|
||||
|
||||
function wireConnectPane() {
|
||||
UI.$('#server-url').value = API.state.serverUrl;
|
||||
UI.$('#api-token').value = API.state.apiToken;
|
||||
syncConnectBtn();
|
||||
['input', 'change'].forEach(ev => {
|
||||
UI.$('#server-url').addEventListener(ev, syncConnectBtn);
|
||||
UI.$('#api-token').addEventListener(ev, syncConnectBtn);
|
||||
});
|
||||
UI.$('#connect-btn').addEventListener('click', async () => {
|
||||
const u = UI.$('#server-url').value.trim();
|
||||
const t = UI.$('#api-token').value.trim();
|
||||
await tryConnect(u, t);
|
||||
});
|
||||
}
|
||||
|
||||
function wireLibraryPane() {
|
||||
UI.$('#disconnect-btn').addEventListener('click', () => {
|
||||
API.disconnect();
|
||||
UI.$('#server-url').value = '';
|
||||
UI.$('#api-token').value = '';
|
||||
syncConnectBtn();
|
||||
UI.showPane('connect');
|
||||
UI.setStatus('#connect-status', 'Disconnected.', 'muted');
|
||||
});
|
||||
|
||||
let searchTimer = null;
|
||||
UI.$('#search-input').addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimer);
|
||||
const q = e.target.value;
|
||||
searchTimer = setTimeout(() => Library.refresh(q), 250);
|
||||
});
|
||||
UI.$('#refresh-btn').addEventListener('click', () => Library.refresh(UI.$('#search-input').value));
|
||||
|
||||
UI.$('#import-proxy-btn').addEventListener('click', async () => {
|
||||
const a = Library.selectedAsset();
|
||||
if (!a) return;
|
||||
UI.$('#import-proxy-btn').disabled = true;
|
||||
UI.$('#import-hires-btn').disabled = true;
|
||||
try { await Import.proxy(a); }
|
||||
catch (e) { UI.hideProgress(); UI.toast('Proxy import failed: ' + e.message, 'error'); }
|
||||
finally { Library.syncActions(); }
|
||||
});
|
||||
|
||||
UI.$('#import-hires-btn').addEventListener('click', async () => {
|
||||
const a = Library.selectedAsset();
|
||||
if (!a) return;
|
||||
UI.$('#import-proxy-btn').disabled = true;
|
||||
UI.$('#import-hires-btn').disabled = true;
|
||||
try { await Import.hires(a); }
|
||||
catch (e) { UI.hideProgress(); UI.toast('Hi-res import failed: ' + e.message, 'error'); }
|
||||
finally { Library.syncActions(); }
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
wireConnectPane();
|
||||
wireLibraryPane();
|
||||
// If we have stored creds, try to reconnect silently. On failure fall
|
||||
// back to the connect pane so the user can retype.
|
||||
if (API.state.serverUrl && API.state.apiToken) {
|
||||
tryConnect(API.state.serverUrl, API.state.apiToken);
|
||||
} else {
|
||||
UI.showPane('connect');
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
61
services/premiere-plugin-uxp/src/ui.js
Normal file
61
services/premiere-plugin-uxp/src/ui.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Small DOM + state helpers used by the rest of the panel. UMD-style (attaches
|
||||
// to window.UI) because UXP loads scripts as classic scripts, not modules.
|
||||
|
||||
(function () {
|
||||
const UI = {};
|
||||
|
||||
UI.$ = function (sel) { return document.querySelector(sel); };
|
||||
|
||||
UI.setHidden = function (sel, hidden) {
|
||||
const el = typeof sel === 'string' ? UI.$(sel) : sel;
|
||||
if (!el) return;
|
||||
el.classList.toggle('hidden', !!hidden);
|
||||
};
|
||||
|
||||
UI.showPane = function (name) {
|
||||
// name in {connect, library}
|
||||
UI.setHidden('#connect-pane', name !== 'connect');
|
||||
UI.setHidden('#library-pane', name !== 'library');
|
||||
};
|
||||
|
||||
UI.setStatus = function (sel, text, kind) {
|
||||
const el = typeof sel === 'string' ? UI.$(sel) : sel;
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.classList.remove('error', 'muted');
|
||||
if (kind === 'error') el.classList.add('error');
|
||||
if (kind === 'muted') el.classList.add('muted');
|
||||
};
|
||||
|
||||
UI.toast = function (msg, kind) {
|
||||
const el = UI.$('#toast');
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden', 'ok', 'error');
|
||||
if (kind) el.classList.add(kind);
|
||||
clearTimeout(UI._toastTimer);
|
||||
UI._toastTimer = setTimeout(() => el.classList.add('hidden'), 6000);
|
||||
};
|
||||
|
||||
UI.showProgress = function (label, pct) {
|
||||
UI.setHidden('#progress-row', false);
|
||||
UI.$('#progress-label').textContent = label;
|
||||
UI.$('#progress-fill').style.width = Math.max(0, Math.min(100, pct || 0)) + '%';
|
||||
};
|
||||
|
||||
UI.hideProgress = function () { UI.setHidden('#progress-row', true); };
|
||||
|
||||
UI.formatBytes = function (n) {
|
||||
if (!Number.isFinite(n)) return '';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
|
||||
};
|
||||
|
||||
UI.sanitizeFilename = function (s) {
|
||||
return String(s || 'asset').replace(/[\\/:*?"<>|]/g, '_').slice(0, 120);
|
||||
};
|
||||
|
||||
window.UI = UI;
|
||||
})();
|
||||
211
services/premiere-plugin-uxp/styles.css
Normal file
211
services/premiere-plugin-uxp/styles.css
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/* Dragonflight UXP panel — design tokens mirror the web UI's dark theme. */
|
||||
|
||||
:root {
|
||||
--bg-0: #0e0f12;
|
||||
--bg-1: #16181d;
|
||||
--bg-2: #1c1f25;
|
||||
--bg-3: #232730;
|
||||
--border: #2a2f3a;
|
||||
--text-1: #e6e8ec;
|
||||
--text-2: #b6bac3;
|
||||
--text-3: #7e848f;
|
||||
--accent: #4f7cff;
|
||||
--accent-hover: #6a91ff;
|
||||
--danger: #e25c5c;
|
||||
--danger-soft: #3a1d20;
|
||||
--ok: #4ec07a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg-0);
|
||||
color: var(--text-1);
|
||||
font: 12px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root { height: 100%; display: flex; flex-direction: column; }
|
||||
|
||||
.pane { display: flex; flex-direction: column; height: 100%; padding: 12px; }
|
||||
.pane.hidden { display: none; }
|
||||
|
||||
/* ── connect screen ──────────────────────────────────────────────── */
|
||||
.brand { text-align: center; margin: 12px 0 18px; }
|
||||
.brand-title { font-size: 18px; font-weight: 600; color: var(--text-1); }
|
||||
.brand-tag {
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 9.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="search"] {
|
||||
width: 100%;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
input:focus { border-color: var(--accent); }
|
||||
|
||||
/* ── buttons ─────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-3);
|
||||
color: var(--text-1);
|
||||
user-select: none;
|
||||
}
|
||||
.btn:disabled { opacity: 0.45; cursor: default; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:not(:disabled):hover { background: var(--accent-hover); }
|
||||
.btn-secondary { background: var(--bg-3); color: var(--text-1); border-color: var(--border); }
|
||||
.btn-link { background: transparent; color: var(--text-3); padding: 4px 6px; font-weight: 500; }
|
||||
.btn-link:hover { color: var(--text-1); }
|
||||
.btn-icon { padding: 4px 8px; background: var(--bg-3); border: 1px solid var(--border); }
|
||||
.btn-icon:hover { background: var(--bg-2); }
|
||||
|
||||
#connect-btn { margin-top: 14px; width: 100%; padding: 8px; }
|
||||
|
||||
/* ── status / dot indicators ─────────────────────────────────────── */
|
||||
.status { font-size: 11px; margin-top: 8px; min-height: 16px; }
|
||||
.status.error { color: var(--danger); }
|
||||
.status.muted { color: var(--text-3); }
|
||||
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
|
||||
.dot-ok { background: var(--ok); }
|
||||
.dot-bad { background: var(--danger); }
|
||||
|
||||
/* ── connected bar ───────────────────────────────────────────────── */
|
||||
.connected-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.connected-host { font-family: ui-monospace, Menlo, monospace; font-size: 11px; color: var(--text-2); flex: 1; }
|
||||
|
||||
.library-controls { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.library-controls input { flex: 1; }
|
||||
|
||||
/* ── asset grid ──────────────────────────────────────────────────── */
|
||||
.asset-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 6px;
|
||||
padding: 2px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.asset-card {
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.asset-card:hover { border-color: var(--text-3); }
|
||||
.asset-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
|
||||
.asset-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: var(--bg-3);
|
||||
display: block;
|
||||
}
|
||||
.asset-thumb-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--bg-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
.asset-name {
|
||||
font-size: 11px;
|
||||
padding: 4px 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.empty { padding: 24px; text-align: center; }
|
||||
.muted { color: var(--text-3); }
|
||||
|
||||
/* ── actions / progress / toast ──────────────────────────────────── */
|
||||
.actions {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.selected-info { font-size: 11px; margin-bottom: 6px; min-height: 16px; }
|
||||
.action-row { display: flex; gap: 6px; }
|
||||
.action-row .btn { flex: 1; }
|
||||
|
||||
.progress-row { margin-top: 8px; }
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-3);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
width: 0%;
|
||||
transition: width 120ms linear;
|
||||
}
|
||||
.progress-label { font-size: 10.5px; color: var(--text-3); margin-top: 4px; }
|
||||
|
||||
.toast {
|
||||
margin-top: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--bg-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-1);
|
||||
}
|
||||
.toast.ok { border-color: var(--ok); color: var(--text-1); }
|
||||
.toast.error { border-color: var(--danger); color: var(--text-1); background: var(--danger-soft); }
|
||||
.hidden { display: none !important; }
|
||||
Loading…
Reference in a new issue