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
+
+ + + + + +
+
+ + + +
+ + + + + + + + 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 = '
Loading…
'; + 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; +})(); diff --git a/services/premiere-plugin-uxp/src/main.js b/services/premiere-plugin-uxp/src/main.js new file mode 100644 index 0000000..4b0cd7d --- /dev/null +++ b/services/premiere-plugin-uxp/src/main.js @@ -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(); + } +})(); diff --git a/services/premiere-plugin-uxp/src/ui.js b/services/premiere-plugin-uxp/src/ui.js new file mode 100644 index 0000000..bd1ff03 --- /dev/null +++ b/services/premiere-plugin-uxp/src/ui.js @@ -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; +})(); diff --git a/services/premiere-plugin-uxp/styles.css b/services/premiere-plugin-uxp/styles.css new file mode 100644 index 0000000..248d429 --- /dev/null +++ b/services/premiere-plugin-uxp/styles.css @@ -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; }