fix(uxp+mam-api): Export Timeline render — xmeml schema + BullMQ job poll

Two cooperating bugs left Export Timeline stuck at "Rendering Hi-Res"
forever:

A. worker emitted "Invalid FCP XML: no sequence element" because
   Timeline.generateFcpXml produced fcpxml (FCP X schema:
   <fcpxml><resources>/<library>/...) while the worker's parseFcpXml
   expects xmeml (FCP 7 schema: <xmeml><sequence>...). Two completely
   different formats.

   Rewrite generateFcpXml to emit xmeml v5 with the structure the
   parser walks:
     xmeml/sequence/{name,duration,rate{timebase,ntsc},
                     media/video/{format/samplecharacteristics,
                                  track[@currentExplodedTrackIndex]
                                  /clipitem/{name,duration,rate,in,out,
                                             start,end,file/{name,pathurl}}}}
   Clipitem in/out are SOURCE frames (the underlying media in/out);
   start/end are TIMELINE frames (the cut position). The worker uses
   the rate timebase to parse them.

B. /api/v1/jobs/:id rejected the panel's polls with
   "Invalid id — must be a UUID". The handlers below correctly parse
   BullMQ-prefixed ids ("conform:42"), but router.param('id',
   validateUuid('id')) ran first and 400'd everything that wasn't a
   UUID. The panel's pollConform swallows the resulting fetch error
   silently and polls forever.

   Drop the validator. Comment in the file explains why.

Bumps panel to v2.2.2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-05-28 13:58:13 -04:00
parent 540d333758
commit 0abef056e7
3 changed files with 84 additions and 32 deletions

View file

@ -1,10 +1,13 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { validateUuid } from '../middleware/errors.js';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
const router = express.Router(); const router = express.Router();
router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // Note: jobs use BullMQ id format "<queueType>:<bullId>" (e.g. "conform:42"),
// NOT UUIDs. The GET/:id, POST/:id/retry, and DELETE/:id handlers below split
// on the colon themselves and look up the queue. Adding a UUID validator
// here would 400 every BullMQ poll the panel makes (which is exactly what
// caused Export Timeline to stall "Rendering Hi-Res" forever — fixed 2026-05-28).
// ── Redis connection ────────────────────────────────────────────────────────── // ── Redis connection ──────────────────────────────────────────────────────────
const parseRedisUrl = (url) => { const parseRedisUrl = (url) => {

View file

@ -2,7 +2,7 @@
"manifestVersion": 5, "manifestVersion": 5,
"id": "net.wilddragon.dragonflight.uxp", "id": "net.wilddragon.dragonflight.uxp",
"name": "Dragonflight MAM", "name": "Dragonflight MAM",
"version": "2.2.1", "version": "2.2.2",
"main": "index.html", "main": "index.html",
"host": { "host": {
"app": "premierepro", "app": "premierepro",

View file

@ -110,40 +110,89 @@
} catch (_) { UI.setHidden('#seq-info-bar', true); } } catch (_) { UI.setHidden('#seq-info-bar', true); }
}; };
// ── FCP XML ────────────────────────────────────────────────────── // ── FCP XML (xmeml / FCP 7) ──────────────────────────────────────
// The worker's parseFcpXml expects the legacy xmeml schema
// (root <xmeml><sequence>), NOT the modern fcpxml schema
// (root <fcpxml><resources><library>...). 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) { Timeline.generateFcpXml = function (td) {
const seqName = UI.escapeXml(td.sequenceName || 'Sequence 1'); const seqName = UI.escapeXml(td.sequenceName || 'Sequence 1');
const fps = td.frameRate || 29.97; const fps = Math.round(td.frameRate || 29.97);
const w = td.width || 1920; const w = td.width || 1920;
const h = td.height || 1080; const h = td.height || 1080;
const clips = td.clips || []; const clips = td.clips || [];
let totalF = 0; let totalF = 0;
clips.forEach(c => { if ((c.timelineOutFrames||0) > totalF) totalF = c.timelineOutFrames; }); clips.forEach(c => { if ((c.timelineOutFrames || 0) > totalF) totalF = c.timelineOutFrames; });
if (totalF < 1) totalF = 100; if (totalF < 1) totalF = 100;
const dur = UI.timecodeFromFrames(totalF, fps);
const frate = UI.formatFrameRate(fps); // Group clips by their video track so each <track> contains its
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE fcpxml>\n<fcpxml version="1.10">\n <resources>\n'; // own clipitems in timeline order. The worker iterates tracks and
xml += ' <format id="r0" frameDuration="' + frate + '" width="' + w + '" height="' + h + '"/>\n'; // collects clipitems off each.
const seen = {}; let rid = 1; const byTrack = {};
clips.forEach(c => { clips.forEach(c => {
const key = c.filePath || c.fileName || 'c' + rid; const ti = Number(c.trackIndex || 0);
if (!seen[key]) { (byTrack[ti] = byTrack[ti] || []).push(c);
seen[key] = 'r' + rid;
const sd = UI.timecodeFromFrames(Math.max(1,(c.sourceOutFrames||100)-(c.sourceInFrames||0)), fps);
xml += ' <asset id="r' + rid + '" name="' + UI.escapeXml(c.fileName||'Clip') + '" src="' + UI.escapeXml(c.filePath||'') + '" duration="' + sd + '" start="0s" format="r0"/>\n';
rid++;
}
}); });
xml += ' </resources>\n <library>\n <event name="Conform Export">\n <project name="' + seqName + '">\n <sequence duration="' + dur + '" format="r0">\n <spine>\n';
clips.forEach(c => { const out = [];
const ref = seen[c.filePath || c.fileName || 'x'] || 'r1'; out.push('<?xml version="1.0" encoding="UTF-8"?>');
const off = UI.timecodeFromFrames(c.timelineInFrames||0, fps); out.push('<!DOCTYPE xmeml>');
const cd = UI.timecodeFromFrames(Math.max(1,(c.timelineOutFrames||1)-(c.timelineInFrames||0)), fps); out.push('<xmeml version="5">');
const si = UI.timecodeFromFrames(c.sourceInFrames||0, fps); out.push(' <sequence>');
xml += ' <clip name="' + UI.escapeXml(c.fileName||'Clip') + '" offset="' + off + '" duration="' + cd + '" start="' + si + '">\n <asset-clip ref="' + ref + '"/>\n </clip>\n'; out.push(' <name>' + seqName + '</name>');
}); out.push(' <duration>' + totalF + '</duration>');
xml += ' </spine>\n </sequence>\n </project>\n </event>\n </library>\n</fcpxml>'; out.push(' <rate><timebase>' + fps + '</timebase><ntsc>FALSE</ntsc></rate>');
return xml; out.push(' <media>');
out.push(' <video>');
out.push(' <format>');
out.push(' <samplecharacteristics>');
out.push(' <width>' + w + '</width>');
out.push(' <height>' + h + '</height>');
out.push(' </samplecharacteristics>');
out.push(' </format>');
let fileId = 1;
Object.keys(byTrack)
.sort((a, b) => Number(a) - Number(b))
.forEach((ti) => {
out.push(' <track currentExplodedTrackIndex="' + ti + '">');
byTrack[ti].forEach((c, idx) => {
const name = UI.escapeXml(c.fileName || 'Clip');
// file:// URI normalises path separators and gives the worker
// a parseable pathurl in case it ever resolves locally.
const rawPath = c.filePath || '';
const pathUri = rawPath
? 'file://localhost/' + UI.escapeXml(String(rawPath).replace(/\\/g, '/').replace(/^\/+/, ''))
: '';
const dur = Math.max(1, (c.sourceOutFrames || 0) - (c.sourceInFrames || 0));
out.push(' <clipitem id="clipitem-' + ti + '-' + idx + '">');
out.push(' <name>' + name + '</name>');
out.push(' <duration>' + dur + '</duration>');
out.push(' <rate><timebase>' + fps + '</timebase></rate>');
out.push(' <in>' + (c.sourceInFrames || 0) + '</in>');
out.push(' <out>' + (c.sourceOutFrames || 0) + '</out>');
out.push(' <start>' + (c.timelineInFrames || 0) + '</start>');
out.push(' <end>' + (c.timelineOutFrames || 0) + '</end>');
out.push(' <file id="file-' + fileId + '">');
out.push(' <name>' + name + '</name>');
if (pathUri) out.push(' <pathurl>' + pathUri + '</pathurl>');
out.push(' </file>');
out.push(' </clipitem>');
fileId++;
});
out.push(' </track>');
});
out.push(' </video>');
out.push(' </media>');
out.push(' </sequence>');
out.push('</xmeml>');
return out.join('\n');
}; };
// ── Push Timeline to MAM ───────────────────────────────────────── // ── Push Timeline to MAM ─────────────────────────────────────────