// timeline.js — v2.1.6 // premierepro API: docs say sync, runtime returns Promises. Await everything. (function () { const Timeline = {}; 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; } // Safe helper — returns the numeric seconds value from a Time object, // or 0 if the object is null/undefined/malformed. function _secs(t) { if (!t) return 0; if (typeof t.seconds === 'number') return t.seconds; return 0; } // ── Read active sequence ───────────────────────────────────────── 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 — open a sequence in the timeline first'); const name = seq.name || (seq.getName ? await seq.getName() : '') || 'Sequence 1'; const settings = await seq.getSettings(); let frameRate = 29.97; try { const fr = settings && settings.videoFrameRate; if (fr && typeof fr.seconds === 'number' && fr.seconds > 0) frameRate = 1 / fr.seconds; } catch (_) {} const width = (settings && settings.videoFrameWidth) || 1920; const height = (settings && settings.videoFrameHeight) || 1080; const clips = []; const trackCount = await seq.getVideoTrackCount(); for (let ti = 0; ti < trackCount; ti++) { const track = await seq.getVideoTrack(ti); if (!track) continue; let items = []; try { items = await track.getTrackItems(1, false); } catch (_) { continue; } if (!items || !items.length) continue; for (const clip of items) { try { const projItem = await clip.getProjectItem(); if (!projItem) continue; let filePath = ''; try { const clipItem = await P.ClipProjectItem.cast(projItem); filePath = await clipItem.getMediaFilePath() || ''; } catch (_) {} const clipName = await clip.getName().catch(() => ''); const fileName = clipName || (filePath ? path.basename(filePath) : 'clip'); // Null-safe time access — non-clip items can return null Time objects const startT = await clip.getStartTime().catch(() => null); const endT = await clip.getEndTime().catch(() => null); const inT = await clip.getInPoint().catch(() => null); const outT = await clip.getOutPoint().catch(() => null); const tlIn = _secs(startT); const tlOut = _secs(endT); const srcIn = _secs(inT); const srcOut = _secs(outT); // Skip filler/gap items where all times are zero if (tlIn === 0 && tlOut === 0 && srcIn === 0 && srcOut === 0 && !filePath) continue; clips.push({ fileName, filePath, trackIndex: ti, timelineInSec: tlIn, timelineOutSec: tlOut, sourceInSec: srcIn, sourceOutSec: srcOut, timelineInFrames: Math.round(tlIn * frameRate), timelineOutFrames: Math.round(tlOut * frameRate), sourceInFrames: Math.round(srcIn * frameRate), sourceOutFrames: Math.round(srcOut * frameRate), }); } catch (clipErr) { console.warn('[df] readActiveSequence: skipped clip on track', ti, '—', clipErr && clipErr.message); } } } return { sequenceName: name, frameRate, width, height, clips }; }; // ── Sequence info bar ──────────────────────────────────────────── Timeline.refreshSeqBar = async function () { try { const P = ppro(); const project = await P.Project.getActiveProject(); if (!project) { UI.setHidden('#seq-info-bar', true); return; } const seq = await project.getActiveSequence(); if (!seq) { UI.setHidden('#seq-info-bar', true); return; } const name = seq.name || ''; 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 (xmeml / FCP 7) ────────────────────────────────────── // The worker's parseFcpXml expects the legacy xmeml schema // (root ), NOT the modern fcpxml schema // (root ...). We previously emitted // fcpxml and every conform job failed with "Invalid FCP XML: no // sequence element". Now we emit xmeml v5 with: // xmeml/sequence/{name,duration,rate,media/video/{format,track*}} // track[@currentExplodedTrackIndex] / clipitem / file{name,pathurl} // clipitem in/out are SOURCE frames; start/end are TIMELINE frames. Timeline.generateFcpXml = function (td) { const seqName = UI.escapeXml(td.sequenceName || 'Sequence 1'); const fps = Math.round(td.frameRate || 29.97); const w = td.width || 1920; const h = td.height || 1080; const clips = td.clips || []; let totalF = 0; clips.forEach(c => { if ((c.timelineOutFrames || 0) > totalF) totalF = c.timelineOutFrames; }); if (totalF < 1) totalF = 100; // Group clips by their video track so each contains its // own clipitems in timeline order. The worker iterates tracks and // collects clipitems off each. const byTrack = {}; clips.forEach(c => { const ti = Number(c.trackIndex || 0); (byTrack[ti] = byTrack[ti] || []).push(c); }); const out = []; out.push(''); out.push(''); out.push(''); out.push(' '); out.push(' ' + seqName + ''); out.push(' ' + totalF + ''); out.push(' ' + fps + 'FALSE'); out.push(' '); out.push(' '); out.push(' '); out.push(' '); out.push(''); return out.join('\n'); }; // ── Push Timeline to MAM ───────────────────────────────────────── Timeline.pushToMAM = async function (seqName, projectId, td) { const resolved = Library.resolveClipsToAssets(td.clips || []); const matched = resolved.filter(c => c.asset_id); if (!matched.length) throw new Error('No clips matched MAM assets — import proxies first so the plugin can map file paths to asset IDs'); 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: td.frameRate, width: td.width, height: td.height }); seqId = existing.id; } else { const created = await API.createSequence(projectId, seqName, td.frameRate, td.width, td.height); seqId = created.id; } await API.pushClips(seqId, 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, }))); return { seqId, matched: matched.length, skipped: resolved.length - matched.length }; }; // ── Conform ────────────────────────────────────────────────────── Timeline.startConform = async function (projectId, seqName, td, opts) { const { seqId } = await Timeline.pushToMAM(seqName, projectId, td); 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(td) }); return job.jobId || job.id; }; Timeline.pollConform = function (jobId, onProgress, onDone) { const t = setInterval(async () => { try { const job = await API.getJob(jobId); onProgress(job.progress||0, job.status); if (job.status==='completed'||job.status==='failed') { clearInterval(t); onDone(job); } } catch (_) {} }, 2000); return t; }; // ── Batch Hi-Res Relink ────────────────────────────────────────── 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.requestExternal(info.url); if (!r.ok) throw new Error('Download HTTP ' + r.status); const buf = await r.arrayBuffer(); await Import._writeBuffer(dest, buf); 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; }; // Relink via ClipProjectItem.changeMediaFilePath — await all calls Timeline._relinkInProject = async function (project, oldPath, newPath) { const P = ppro(); const root = await project.getRootItem(); let count = 0; async function walk(item) { let children = []; try { children = await item.getItems(); } catch (_) {} for (const child of children) { try { const ci = await P.ClipProjectItem.cast(child); const mp = await ci.getMediaFilePath(); if (mp && mp === oldPath) { await ci.changeMediaFilePath(newPath, false); count++; } } catch (_) { try { await walk(child); } catch (__) {} } } } await walk(root); // Fallback: findItemsMatchingMediaPath if (count === 0) { try { const rootCi = await P.ClipProjectItem.cast(root); const matches = await rootCi.findItemsMatchingMediaPath(oldPath, true); for (const item of (matches||[])) { try { const ci = await P.ClipProjectItem.cast(item); await ci.changeMediaFilePath(newPath, false); count++; } catch (_) {} } } catch (_) {} } if (count === 0) throw new Error('No matching clips found for: ' + (oldPath||'unknown')); return count; }; // ── Local Export ───────────────────────────────────────────────── // Server trims each timeline clip's hi-res via FFMPEG, then we download // the trimmed segments and relink the project items to them. // CAVEAT: relink keys on the source media path, so a source used by // multiple timeline clips with different in/out points will relink to a // single segment (last one wins). Common single-use case is exact. Timeline.localExport = async function (resolvedClips, onProgress) { const P = ppro(); const project = await P.Project.getActiveProject(); if (!project) throw new Error('No active Premiere project'); const matched = (resolvedClips || []).filter(c => c.asset_id); if (!matched.length) throw new Error('No clips matched MAM assets to export'); const payload = matched.map(c => ({ assetId: c.asset_id, filename: c.fileName || (c.filePath ? path.basename(c.filePath) : 'clip'), sourceInFrames: c.sourceInFrames, sourceOutFrames: c.sourceOutFrames, timelineInFrames: c.timelineInFrames, timelineOutFrames: c.timelineOutFrames, trackIndex: c.trackIndex, })); onProgress && onProgress('Requesting trim of ' + matched.length + ' clip(s)…', 10); const job = await API.batchTrim(payload); const jobId = job.jobId; const clipByInstance = {}; (job.clips || []).forEach((cr, i) => { if (cr.clipInstanceId) clipByInstance[cr.clipInstanceId] = matched[i]; }); // Poll until every segment is ready (s3Key set) or the job fails. const ready = {}; await new Promise((resolve, reject) => { const t = setInterval(async () => { try { const st = await API.getTrimStatus(jobId); const clips = st.clips || []; const completed = clips.filter(c => c.status === 'completed' && c.s3Key); onProgress && onProgress('Trimming on server… (' + completed.length + '/' + clips.length + ')', 15 + (completed.length / Math.max(1, clips.length)) * 45); if (st.status === 'failed') { clearInterval(t); reject(new Error('Server trim job failed')); return; } if (clips.length && completed.length === clips.length) { clearInterval(t); completed.forEach(c => { ready[c.clipInstanceId] = c; }); resolve(); } } catch (_) { /* transient — keep polling */ } }, 2000); }); // Download each segment and relink the source media path to it. const results = { succeeded: 0, failed: 0, errors: [] }; const ids = Object.keys(ready); for (let i = 0; i < ids.length; i++) { const cid = ids[i]; const clip = clipByInstance[cid]; if (!clip) continue; try { onProgress && onProgress('Downloading segment ' + (i + 1) + '/' + ids.length + '…', 60 + (i / ids.length) * 35); const seg = await API.getTempSegmentUrl(cid); const ext = (seg.s3Key && seg.s3Key.split('.').pop()) || 'mov'; const base = UI.sanitizeFilename((clip.fileName || 'clip') + '-trim-' + cid.slice(0, 8) + '.' + ext); const dest = await Import._tempPath(base); const r = await API.requestExternal(seg.url); if (!r.ok) throw new Error('Segment download HTTP ' + r.status); await Import._writeBuffer(dest, await r.arrayBuffer()); if (clip.filePath) await Timeline._relinkInProject(project, clip.filePath, dest); results.succeeded++; } catch (e) { results.failed++; results.errors.push((clip && clip.fileName || 'clip') + ': ' + e.message); } } return results; }; window.Timeline = Timeline; })();