From cd18988d6d7cb974c4690ac398b10b70ed4961e2 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 28 May 2026 00:57:59 -0400 Subject: [PATCH] =?UTF-8?q?UXP=20v2.1.0:=20api.js=20=E2=80=94=20add=20proj?= =?UTF-8?q?ects,=20live-path,=20sequences,=20conform,=20batch-trim=20endpo?= =?UTF-8?q?ints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/premiere-plugin-uxp/src/api.js | 144 +++++++++++++++++++----- 1 file changed, 118 insertions(+), 26 deletions(-) diff --git a/services/premiere-plugin-uxp/src/api.js b/services/premiere-plugin-uxp/src/api.js index 61d22b2..a0c7eb8 100644 --- a/services/premiere-plugin-uxp/src/api.js +++ b/services/premiere-plugin-uxp/src/api.js @@ -1,9 +1,7 @@ -// 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. +// Dragonflight API client v2.1.0 +// Wraps UXP fetch() with Bearer auth + manual redirect follow (UXP strips +// Authorization across origins per Adobe security policy). +// Persists serverUrl + apiToken in localStorage. (function () { const API = {}; @@ -31,22 +29,50 @@ 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. + // Core request. `urlOrPath` may be a path (/api/...) or absolute URL. + // Bearer header added only for same-server requests. 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 (!isAbs || url.startsWith(base)) { 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 })); }; + // Manual redirect follow: UXP strips Authorization on cross-origin + // redirects — so we follow redirects manually and drop Bearer on hop + // to a different host (e.g., proxy URL → presigned S3). + API.requestFollow = async function (urlOrPath, opts) { + opts = opts || {}; + const isAbs = /^https?:\/\//i.test(urlOrPath); + const base = trimUrl(API.state.serverUrl); + let current = isAbs ? urlOrPath : (base + urlOrPath); + + for (let hop = 0; hop < 6; hop++) { + const headers = Object.assign({}, opts.headers || {}); + const sameOrigin = current.startsWith(base); + if (sameOrigin && API.state.apiToken) { + headers['Authorization'] = 'Bearer ' + API.state.apiToken; + } else { + delete headers['Authorization']; + } + const r = await fetch(current, Object.assign({}, opts, { headers, redirect: 'manual' })); + if (r.status >= 200 && r.status < 300) return r; + if (r.status >= 300 && r.status < 400) { + const loc = r.headers.get('location'); + if (!loc) throw new Error('Redirect with no Location header'); + current = /^https?:\/\//i.test(loc) ? loc : new URL(loc, current).toString(); + continue; + } + throw new Error('HTTP ' + r.status); + } + throw new Error('Too many redirects'); + }; + API.json = async function (path, opts) { const r = await API.request(path, opts); if (!r.ok) { @@ -56,13 +82,12 @@ return r.json(); }; - // GET /api/v1/auth/me — used as the connect probe. Returns 200 on a valid - // bearer, 401 otherwise. + // ── Auth ───────────────────────────────────────────────────────── 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'); + throw new Error('Server URL and API token required'); } const me = await API.json('/api/v1/auth/me'); API.state.connected = true; @@ -70,20 +95,21 @@ return me; }; - API.disconnect = function () { - API.clear(); + API.disconnect = function () { API.clear(); }; + + // ── Assets ─────────────────────────────────────────────────────── + API.listAssets = async function (query, projectId) { + const p = new URLSearchParams({ limit: '60' }); + if (query) p.set('q', query); + if (projectId && projectId !== 'all') p.set('project_id', projectId); + return API.json('/api/v1/assets?' + p.toString()); }; - // 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()); + API.getAsset = async function (assetId) { + return API.json('/api/v1/assets/' + assetId); }; - // Resolve a proxy stream URL → returns an absolute URL we can fetch. - // /stream returns { url: '/api/v1/assets//video', type: 'mp4', source }. + // /stream → { url, type, 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'); @@ -92,13 +118,79 @@ 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' }. + // /hires → { url, filename, ext, file_size, type } 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; }; + // /live-path → { win_path, posix_path, display_name } + API.getLivePath = async function (assetId) { + return API.json('/api/v1/assets/' + assetId + '/live-path'); + }; + + // Batch trim: POST /api/v1/assets/batch-trim + API.batchTrim = async function (clips) { + return API.json('/api/v1/assets/batch-trim', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clips }), + }); + }; + + // ── Projects ───────────────────────────────────────────────────── + API.listProjects = async function () { + const data = await API.json('/api/v1/projects'); + return Array.isArray(data) ? data : []; + }; + + // ── Sequences ──────────────────────────────────────────────────── + API.listSequences = async function (projectId) { + const p = new URLSearchParams(); + if (projectId) p.set('project_id', projectId); + const data = await API.json('/api/v1/sequences?' + p.toString()); + return Array.isArray(data) ? data : []; + }; + + API.createSequence = async function (projectId, name, frameRate, width, height) { + return API.json('/api/v1/sequences', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project_id: projectId, name, frame_rate: frameRate, width, height }), + }); + }; + + API.updateSequence = async function (seqId, fields) { + return API.json('/api/v1/sequences/' + seqId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields), + }); + }; + + API.pushClips = async function (seqId, clips) { + const r = await API.request('/api/v1/sequences/' + seqId + '/clips', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(clips), + }); + if (!r.ok) throw new Error('Clip push HTTP ' + r.status); + return r.json(); + }; + + API.startConform = async function (seqId, opts) { + return API.json('/api/v1/sequences/' + seqId + '/conform', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(opts), + }); + }; + + // ── Jobs ───────────────────────────────────────────────────────── + API.getJob = async function (jobId) { + return API.json('/api/v1/jobs/' + jobId); + }; + window.API = API; })();