From 066718c968ebf76a701b1c315fdcccf6ef70e166 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 28 May 2026 01:00:19 -0400 Subject: [PATCH] =?UTF-8?q?UXP=20v2.1.0:=20timeline.js=20=E2=80=94=20new?= =?UTF-8?q?=20module:=20sequence=20read,=20FCP=20XML,=20export,=20conform,?= =?UTF-8?q?=20batch=20relink=20via=20UXP=20premierepro=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/premiere-plugin-uxp/src/timeline.js | 286 +++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 services/premiere-plugin-uxp/src/timeline.js diff --git a/services/premiere-plugin-uxp/src/timeline.js b/services/premiere-plugin-uxp/src/timeline.js new file mode 100644 index 0000000..e238907 --- /dev/null +++ b/services/premiere-plugin-uxp/src/timeline.js @@ -0,0 +1,286 @@ +// timeline.js — v2.1.0 +// Reads active Premiere sequence via UXP premierepro API. +// Replaces all CSInterface.evalScript() calls that died in PPro 26. +// Also: FCP XML generation, push to MAM, conform, batch hi-res relink. + +(function () { + const Timeline = {}; + + // ── premierepro lazy require ──────────────────────────────────── + function ppro() { + if (Timeline._ppro) return Timeline._ppro; + try { Timeline._ppro = require('premierepro'); } + catch (e) { throw new Error('UXP premierepro unavailable: ' + e.message); } + return Timeline._ppro; + } + + // ── Read active sequence ───────────────────────────────────────── + // Returns { sequenceName, frameRate, width, height, clips[] } + // clips: { fileName, filePath, trackIndex, timelineInFrames, timelineOutFrames, + // sourceInFrames, sourceOutFrames } + Timeline.readActiveSequence = async function () { + const P = ppro(); + const project = await P.Project.getActiveProject(); + if (!project) throw new Error('No active Premiere project'); + + const seq = await project.getActiveSequence(); + if (!seq) throw new Error('No active sequence in the project'); + + const name = await seq.getName().catch(() => 'Sequence 1'); + const settings = await seq.getSettings().catch(() => ({})); + const frameRate = settings.videoFrameRate ? (1 / settings.videoFrameRate) : 29.97; + const width = settings.videoFrameWidth || 1920; + const height = settings.videoFrameHeight || 1080; + + // Walk video tracks + const clips = []; + let trackCount = 0; + try { trackCount = await seq.getVideoTrackCount(); } catch (_) {} + for (let ti = 0; ti < trackCount; ti++) { + let track; + try { track = await seq.getVideoTrack(ti); } catch (_) { continue; } + let clipCount = 0; + try { clipCount = await track.getClipCount(); } catch (_) {} + for (let ci = 0; ci < clipCount; ci++) { + try { + const clip = await track.getClip(ci); + const projItem = await clip.getProjectItem().catch(() => null); + if (!projItem) continue; + const mediaPath = await projItem.getMediaPath().catch(() => ''); + const itemName = await projItem.getName().catch(() => ''); + const inPoint = await clip.getInPoint().catch(() => ({ ticks: 0 })); + const outPoint = await clip.getOutPoint().catch(() => ({ ticks: 0 })); + const srcIn = await clip.getSourceInPoint().catch(() => ({ ticks: 0 })); + const srcOut = await clip.getSourceOutPoint().catch(() => ({ ticks: 0 })); + // Premiere ticks = 1/254016000000 s. Convert to frames. + const TICK = 254016000000; + function ticksToFrames(t) { return Math.round((t.ticks / TICK) * frameRate); } + clips.push({ + fileName: itemName || (mediaPath ? mediaPath.split(/[\\/]/).pop() : 'clip'), + filePath: mediaPath || '', + trackIndex: ti, + timelineInFrames: ticksToFrames(inPoint), + timelineOutFrames: ticksToFrames(outPoint), + sourceInFrames: ticksToFrames(srcIn), + sourceOutFrames: ticksToFrames(srcOut), + }); + } catch (_) {} + } + } + + return { sequenceName: name, frameRate, width, height, clips }; + }; + + // ── Active sequence info bar ───────────────────────────────────── + Timeline.refreshSeqBar = async function () { + try { + const P = ppro(); + const project = await P.Project.getActiveProject().catch(() => null); + if (!project) { UI.setHidden('#seq-info-bar', true); return; } + const seq = await project.getActiveSequence().catch(() => null); + if (!seq) { UI.setHidden('#seq-info-bar', true); return; } + const name = await seq.getName().catch(() => ''); + if (name) { + document.getElementById('seq-info-name').textContent = name; + UI.setHidden('#seq-info-bar', false); + } else { + UI.setHidden('#seq-info-bar', true); + } + } catch (_) { + UI.setHidden('#seq-info-bar', true); + } + }; + + // ── FCP XML generation ─────────────────────────────────────────── + Timeline.generateFcpXml = function (timelineData) { + const seqName = UI.escapeXml(timelineData.sequenceName || 'Sequence 1'); + const frameRate = timelineData.frameRate || 29.97; + const width = timelineData.width || 1920; + const height = timelineData.height || 1080; + const clips = timelineData.clips || []; + + let totalFrames = 0; + clips.forEach(c => { if ((c.timelineOutFrames || 0) > totalFrames) totalFrames = c.timelineOutFrames; }); + if (totalFrames < 1) totalFrames = 100; + + const duration = UI.timecodeFromFrames(totalFrames, frameRate); + const frateStr = UI.formatFrameRate(frameRate); + + let xml = '\n'; + xml += '\n'; + xml += '\n'; + xml += ' \n'; + xml += ' \n'; + + const seen = {}; let rid = 1; + clips.forEach(clip => { + const key = clip.filePath || clip.fileName || 'c' + rid; + if (!seen[key]) { + seen[key] = 'r' + rid; + const srcDur = UI.timecodeFromFrames( + Math.max(1, (clip.sourceOutFrames || 100) - (clip.sourceInFrames || 0)), frameRate + ); + xml += ' \n'; + rid++; + } + }); + xml += ' \n'; + xml += ' \n \n \n'; + xml += ' \n \n'; + + clips.forEach(clip => { + const resId = seen[clip.filePath || clip.fileName || 'x'] || 'r1'; + const off = UI.timecodeFromFrames(clip.timelineInFrames || 0, frameRate); + const dur = UI.timecodeFromFrames( + Math.max(1, (clip.timelineOutFrames || 1) - (clip.timelineInFrames || 0)), frameRate + ); + const srcIn = UI.timecodeFromFrames(clip.sourceInFrames || 0, frameRate); + xml += ' \n'; + xml += ' \n'; + xml += ' \n'; + }); + + xml += ' \n \n \n \n \n'; + return xml; + }; + + // ── Push Timeline to MAM ───────────────────────────────────────── + Timeline.pushToMAM = async function (seqName, projectId, timelineData) { + const resolved = Library.resolveClipsToAssets(timelineData.clips || []); + const matched = resolved.filter(c => c.asset_id); + if (matched.length === 0) throw new Error('No clips matched MAM assets — import proxies first'); + + // Upsert sequence + const seqs = await API.listSequences(projectId); + let seqId; + const existing = seqs.find(s => s.name === seqName); + if (existing) { + await API.updateSequence(existing.id, { + frame_rate: timelineData.frameRate, width: timelineData.width, height: timelineData.height, + }); + seqId = existing.id; + } else { + const created = await API.createSequence( + projectId, seqName, timelineData.frameRate, timelineData.width, timelineData.height + ); + seqId = created.id; + } + + const payload = matched.map(c => ({ + asset_id: c.asset_id, + track: c.trackIndex, + timeline_in_frames: c.timelineInFrames, + timeline_out_frames: c.timelineOutFrames, + source_in_frames: c.sourceInFrames, + source_out_frames: c.sourceOutFrames, + })); + await API.pushClips(seqId, payload); + + return { seqId, matched: matched.length, skipped: resolved.length - matched.length }; + }; + + // ── Conform ────────────────────────────────────────────────────── + // opts: { codec, quality, resolution, audio } + // Returns jobId for polling. + Timeline.startConform = async function (projectId, seqName, timelineData, opts) { + // Upsert sequence to get seqId + const { seqId } = await Timeline.pushToMAM(seqName, projectId, timelineData); + + const resMap = { '1080p': [1920,1080], '720p': [1280,720], 'uhd': [3840,2160], 'source': [0,0] }; + const [w, h] = resMap[opts.resolution] || [1920,1080]; + + const job = await API.startConform(seqId, { + codec: opts.codec, quality: opts.quality, + width: w || undefined, height: h || undefined, + audio: opts.audio, + fcp_xml: Timeline.generateFcpXml(timelineData), + }); + return job.jobId || job.id; + }; + + Timeline.pollConform = function (jobId, onProgress, onDone) { + let timer = setInterval(async () => { + try { + const job = await API.getJob(jobId); + const pct = job.progress || 0; + onProgress(pct, job.status); + if (job.status === 'completed' || job.status === 'failed') { + clearInterval(timer); + onDone(job); + } + } catch (_) {} + }, 2000); + return timer; + }; + + // ── Batch Hi-Res Relink ────────────────────────────────────────── + // Takes selectedClips (array of resolved clip objects with asset_id), + // downloads hi-res for each, relinks in Premiere. + Timeline.batchRelink = async function (selectedClips) { + const P = ppro(); + const project = await P.Project.getActiveProject(); + if (!project) throw new Error('No active Premiere project'); + + const results = { succeeded: 0, failed: 0, errors: [] }; + + for (const clip of selectedClips) { + if (!clip.asset_id) continue; + try { + UI.showProgress('Fetching hi-res for ' + clip.fileName + '…', 20); + const info = await API.getHiresInfo(clip.asset_id); + const safeName = UI.sanitizeFilename(info.filename || clip.fileName + '.' + (info.ext || 'mxf')); + const dest = await Import._tempPath(safeName); + + UI.showProgress('Downloading ' + safeName + '…', 30); + const r = await API.requestFollow(info.url, {}); + if (!r.ok) throw new Error('Download HTTP ' + r.status); + await Import._streamToFile(r, dest, ({ received, total }) => { + const pct = total ? 30 + (received / total) * 50 : 30; + UI.showProgress('Downloading ' + UI.formatBytes(received) + '…', pct); + }); + + // Relink: walk project items, find by old path, changeMediaPath + UI.showProgress('Relinking ' + clip.fileName + '…', 85); + await Timeline._relinkInProject(project, clip.filePath, dest); + results.succeeded++; + } catch (e) { + results.failed++; + results.errors.push(clip.fileName + ': ' + e.message); + } + } + return results; + }; + + // Walk all project items and changeMediaPath for items matching oldPath. + Timeline._relinkInProject = async function (project, oldPath, newPath) { + const root = await project.getRootItem(); + let count = 0; + async function walk(item) { + try { + const children = await item.getItems(); + for (const child of children) { + try { + const mp = await child.getMediaPath().catch(() => null); + if (mp && mp === oldPath) { + await child.changeMediaPath(newPath, true); + count++; + } + } catch (_) {} + // Recurse into bins + try { + const sub = await child.getItems(); + if (sub && sub.length) await walk(child); + } catch (_) {} + } + } catch (_) {} + } + await walk(root); + if (count === 0) throw new Error('No matching clips found for ' + (oldPath || 'unknown path')); + return count; + }; + + window.Timeline = Timeline; +})();