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:
parent
540d333758
commit
0abef056e7
3 changed files with 84 additions and 32 deletions
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue