2026-04-07 21:58:19 -04:00
|
|
|
import { join } from 'path';
|
2026-05-19 23:06:54 -04:00
|
|
|
import { unlink, writeFile, mkdir, rm } from 'fs/promises';
|
2026-04-07 21:58:19 -04:00
|
|
|
import { tmpdir } from 'os';
|
2026-05-28 15:49:01 -04:00
|
|
|
import { Queue } from 'bullmq';
|
2026-04-07 21:58:19 -04:00
|
|
|
import { query } from '../db/client.js';
|
|
|
|
|
import { downloadFromS3, uploadToS3 } from '../s3/client.js';
|
fix(worker): conform — 2-pass strategy (normalise on trim, demux on concat)
ffmpeg 8.x's concat filter kept dying with the opaque
[fc#0] Error sending frames to consumers: Invalid argument
even after we locked fps + sample rate + pixel format + SAR in the
filter graph. Mixed sources (AV1+H.264, 23.98+60 fps, 44100+48000 Hz,
tv-range+unspecified-range pixel format) just don't survive the
concat filter cleanly in this build.
Switch to the more reliable 2-pass pattern:
1. At the trim step, re-encode each segment to a uniform intermediate
spec: libx264 ultrafast, 1920x1080 (letterboxed), yuv420p,
seqFps target rate, 48kHz stereo AAC. Per-segment ffmpeg.
2. At the concat step, use the concat *demuxer*. Because every input
now matches exactly, the demuxer is well-behaved. Transcode the
concatenated stream to the final target codec (ProRes 422 HQ etc).
Costs an extra intermediate encode (libx264 ultrafast ≈ realtime on
this hardware) but eliminates the filter-graph fragility on mixed-
source timelines, which is the workload that actually matters.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:34:52 -04:00
|
|
|
import { trimSegment, concatSegments, runFFmpeg, getMediaInfo } from '../ffmpeg/executor.js';
|
2026-04-07 21:58:19 -04:00
|
|
|
import { parseEDL } from '../edl/parser.js';
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
import { XMLParser } from 'fast-xml-parser';
|
2026-04-07 21:58:19 -04:00
|
|
|
|
|
|
|
|
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
|
|
|
|
|
2026-05-28 15:49:01 -04:00
|
|
|
// Used to queue a proxy build for the conformed output so the library /
|
|
|
|
|
// asset viewer has a browser-playable H.264 preview. Without this the
|
|
|
|
|
// browser hits MEDIA_ERR_SRC_NOT_SUPPORTED on ProRes / DNxHR outputs.
|
|
|
|
|
const parseRedisUrl = (url) => {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
|
|
|
|
|
} catch { return { host: 'localhost', port: 6379 }; }
|
|
|
|
|
};
|
|
|
|
|
const proxyQueue = new Queue('proxy', {
|
|
|
|
|
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
|
|
|
|
|
});
|
|
|
|
|
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
const xmlParser = new XMLParser({
|
|
|
|
|
ignoreAttributes: false,
|
|
|
|
|
attributeNamePrefix: '@_',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function parseFcpXml(xmlContent) {
|
|
|
|
|
const doc = xmlParser.parse(xmlContent);
|
|
|
|
|
const sequence = doc?.xmeml?.sequence;
|
|
|
|
|
if (!sequence) throw new Error('Invalid FCP XML: no sequence element');
|
|
|
|
|
|
|
|
|
|
const name = sequence.name || 'Untitled';
|
|
|
|
|
const rate = sequence?.rate?.timebase ? parseInt(sequence.rate.timebase, 10) : 29.97;
|
|
|
|
|
const width = parseInt(sequence?.media?.video?.format?.samplecharacteristics?.width || 1920, 10);
|
|
|
|
|
const height = parseInt(sequence?.media?.video?.format?.samplecharacteristics?.height || 1080, 10);
|
|
|
|
|
|
|
|
|
|
const clips = [];
|
|
|
|
|
const videoTracks = sequence?.media?.video?.track || [];
|
|
|
|
|
const tracks = Array.isArray(videoTracks) ? videoTracks : [videoTracks];
|
|
|
|
|
|
|
|
|
|
for (const track of tracks) {
|
2026-05-26 09:41:33 -04:00
|
|
|
const trackNum = parseInt(track?.['@_currentExplodedTrackIndex'] || 0, 10);
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
const trackItems = track?.clipitem || [];
|
|
|
|
|
const items = Array.isArray(trackItems) ? trackItems : [trackItems];
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
if (!item) continue;
|
|
|
|
|
const fileUrl = item?.file?.name || item?.file?.pathurl || '';
|
|
|
|
|
const fileName = fileUrl.split('/').pop() || fileUrl.split('\\').pop() || 'unknown';
|
|
|
|
|
const srcIn = parseFrame(item?.in?.toString() || '0', rate);
|
|
|
|
|
const srcOut = parseFrame(item?.out?.toString() || '0', rate);
|
|
|
|
|
const recIn = parseFrame(item?.start?.toString() || '0', rate);
|
|
|
|
|
const recOut = parseFrame(item?.end?.toString() || '0', rate);
|
|
|
|
|
const duration = parseFrame(item?.duration?.toString() || '0', rate);
|
|
|
|
|
|
|
|
|
|
if (srcOut <= srcIn || recOut <= recIn) continue;
|
|
|
|
|
|
|
|
|
|
clips.push({
|
|
|
|
|
trackIndex: trackNum,
|
|
|
|
|
fileName,
|
|
|
|
|
fileUrl,
|
|
|
|
|
sourceInFrames: srcIn,
|
|
|
|
|
sourceOutFrames: srcOut,
|
|
|
|
|
timelineInFrames: recIn,
|
|
|
|
|
timelineOutFrames: recOut,
|
|
|
|
|
duration,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { name, frameRate: rate, width, height, clips };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseFrame(value, fps) {
|
|
|
|
|
// FCP XML stores timecode or frame count
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
// If it's a plain number, return as-is
|
|
|
|
|
if (/^\d+$/.test(trimmed)) return parseInt(trimmed, 10);
|
|
|
|
|
// HH:MM:SS:FF or HH:MM:SS;FF
|
|
|
|
|
const parts = trimmed.split(/[:;]/);
|
|
|
|
|
if (parts.length === 4) {
|
|
|
|
|
const hh = parseInt(parts[0], 10);
|
|
|
|
|
const mm = parseInt(parts[1], 10);
|
|
|
|
|
const ss = parseInt(parts[2], 10);
|
|
|
|
|
const ff = parseInt(parts[3], 10);
|
|
|
|
|
return hh * 3600 * fps + mm * 60 * fps + ss * fps + ff;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 21:58:19 -04:00
|
|
|
export const conformWorker = async (job) => {
|
2026-05-28 14:08:49 -04:00
|
|
|
const { edl, fcpXml, projectId, sequenceId, sequenceName, frameRate, codec, quality, resolution, audio } = job.data;
|
2026-05-18 23:28:13 -04:00
|
|
|
const jobId = job.id;
|
2026-04-07 21:58:19 -04:00
|
|
|
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
const tmpDir = tmpdir();
|
|
|
|
|
const segmentsDir = join(tmpDir, `segments-${jobId}`);
|
2026-05-18 23:28:13 -04:00
|
|
|
const segmentListPath = join(tmpDir, `segments-${jobId}.txt`);
|
2026-05-28 15:40:53 -04:00
|
|
|
// Container per codec — ProRes / DNxHR live in QuickTime (MOV); MP4 only
|
|
|
|
|
// accepts H.264/H.265 and a handful of others. The earlier .mp4 hard-code
|
|
|
|
|
// tripped ffmpeg with:
|
|
|
|
|
// [mp4] Could not find tag for codec prores in stream #0,
|
|
|
|
|
// codec not currently supported in container
|
|
|
|
|
const outputExt =
|
|
|
|
|
(codec === 'prores' || codec === 'prores_hq' || codec === 'prores_4444' || codec === 'dnxhr_hq')
|
|
|
|
|
? 'mov' : 'mp4';
|
|
|
|
|
const outputPath = join(tmpDir, `output-${jobId}.${outputExt}`);
|
2026-04-07 21:58:19 -04:00
|
|
|
|
|
|
|
|
try {
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
let edits = [];
|
|
|
|
|
let seqName = sequenceName || 'Conformed';
|
|
|
|
|
let seqFps = parseFloat(frameRate) || 29.97;
|
|
|
|
|
|
2026-05-28 14:08:49 -04:00
|
|
|
// ── Resolve edits ────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Preference order:
|
|
|
|
|
// 1) sequenceId — read sequence_clips, which the Premiere panel
|
|
|
|
|
// populated with authoritative asset_id mappings on push. This
|
|
|
|
|
// avoids any filename matching, which is brittle because the
|
|
|
|
|
// panel's local Premiere file paths (e.g. "dragonflight-<name>"
|
|
|
|
|
// with sanitised characters) do not match the original MAM
|
|
|
|
|
// filenames in the assets table.
|
|
|
|
|
// 2) edl — legacy EDL input, filename-resolved.
|
|
|
|
|
// 3) fcpXml — parse the XML for clipitems, filename-resolved.
|
|
|
|
|
//
|
|
|
|
|
// The XML is still parsed when sequenceId is also provided, because
|
|
|
|
|
// we want its sequence name + frame rate metadata even when the
|
|
|
|
|
// authoritative clip list comes from the DB.
|
|
|
|
|
if (sequenceId) {
|
|
|
|
|
await job.updateProgress(5);
|
|
|
|
|
console.log(`[conform] Resolving edits from sequence_clips for sequence ${sequenceId}`);
|
|
|
|
|
const clipRows = await query(
|
|
|
|
|
`SELECT sc.asset_id, sc.source_in_frames, sc.source_out_frames,
|
|
|
|
|
sc.timeline_in_frames, sc.timeline_out_frames, sc.track,
|
|
|
|
|
a.original_s3_key, a.filename
|
|
|
|
|
FROM sequence_clips sc
|
|
|
|
|
JOIN assets a ON a.id = sc.asset_id
|
|
|
|
|
WHERE sc.sequence_id = $1
|
|
|
|
|
ORDER BY sc.timeline_in_frames ASC, sc.track ASC`,
|
|
|
|
|
[sequenceId]
|
|
|
|
|
);
|
|
|
|
|
if (!clipRows.rows.length) {
|
|
|
|
|
throw new Error('Sequence has no clips. Push the timeline from Premiere first.');
|
|
|
|
|
}
|
|
|
|
|
edits = clipRows.rows.map((r, i) => ({
|
|
|
|
|
editNumber: i + 1,
|
|
|
|
|
reelName: r.filename,
|
|
|
|
|
asset_id: r.asset_id,
|
|
|
|
|
original_s3_key: r.original_s3_key,
|
|
|
|
|
sourceIn: r.source_in_frames,
|
|
|
|
|
sourceOut: r.source_out_frames,
|
|
|
|
|
}));
|
|
|
|
|
// Parse XML for sequence-level metadata if it's also provided.
|
|
|
|
|
if (fcpXml) {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = parseFcpXml(fcpXml);
|
|
|
|
|
seqName = parsed.name || seqName;
|
|
|
|
|
seqFps = parsed.frameRate || seqFps;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn(`[conform] XML metadata parse skipped: ${e.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (edl) {
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
await job.updateProgress(5);
|
|
|
|
|
console.log(`[conform] Parsing EDL for job ${jobId}`);
|
|
|
|
|
edits = parseEDL(edl).map((e, i) => ({
|
|
|
|
|
editNumber: e.editNumber || i + 1,
|
|
|
|
|
reelName: e.reelName,
|
|
|
|
|
sourceIn: e.sourceIn,
|
|
|
|
|
sourceOut: e.sourceOut,
|
|
|
|
|
}));
|
|
|
|
|
} else if (fcpXml) {
|
|
|
|
|
await job.updateProgress(5);
|
|
|
|
|
console.log(`[conform] Parsing FCP XML for job ${jobId}`);
|
|
|
|
|
const parsed = parseFcpXml(fcpXml);
|
|
|
|
|
seqName = parsed.name || seqName;
|
|
|
|
|
seqFps = parsed.frameRate || seqFps;
|
|
|
|
|
edits = parsed.clips.map((c, i) => ({
|
|
|
|
|
editNumber: i + 1,
|
|
|
|
|
reelName: c.fileName,
|
|
|
|
|
sourceIn: c.sourceInFrames,
|
|
|
|
|
sourceOut: c.sourceOutFrames,
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error('No input provided — expected edl or fcpXml in job data');
|
|
|
|
|
}
|
2026-04-07 21:58:19 -04:00
|
|
|
|
2026-05-15 23:40:13 -04:00
|
|
|
await mkdir(segmentsDir, { recursive: true });
|
2026-04-07 21:58:19 -04:00
|
|
|
|
|
|
|
|
let processedEdits = 0;
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
const concatList = [];
|
2026-04-07 21:58:19 -04:00
|
|
|
|
|
|
|
|
for (const edit of edits) {
|
2026-05-15 23:40:13 -04:00
|
|
|
await job.updateProgress(Math.min(5 + (processedEdits / edits.length) * 50, 55));
|
2026-04-07 21:58:19 -04:00
|
|
|
console.log(`[conform] Processing edit ${edit.editNumber}: ${edit.reelName}`);
|
|
|
|
|
|
2026-05-28 14:08:49 -04:00
|
|
|
// If the edit was resolved from sequence_clips above, the asset's
|
|
|
|
|
// original_s3_key is already attached — skip the filename lookup
|
|
|
|
|
// entirely (it would 0-match anyway because the panel's reelName
|
|
|
|
|
// is the local Premiere file path with "dragonflight-" prefix).
|
|
|
|
|
let sourceKey = edit.original_s3_key || null;
|
|
|
|
|
|
|
|
|
|
if (!sourceKey) {
|
|
|
|
|
// Legacy path (EDL or fcpXml without sequenceId): match by filename,
|
|
|
|
|
// preferring same-project assets to avoid cross-project collisions.
|
|
|
|
|
let assetRes;
|
|
|
|
|
if (projectId) {
|
|
|
|
|
assetRes = await query(
|
|
|
|
|
`SELECT id, original_s3_key FROM assets
|
|
|
|
|
WHERE filename = $1 AND project_id = $2
|
|
|
|
|
LIMIT 1`,
|
|
|
|
|
[edit.reelName, projectId]
|
|
|
|
|
);
|
|
|
|
|
if (assetRes.rows.length === 0) {
|
|
|
|
|
assetRes = await query(
|
|
|
|
|
'SELECT id, original_s3_key FROM assets WHERE filename = $1 LIMIT 1',
|
|
|
|
|
[edit.reelName]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-05-26 07:35:13 -04:00
|
|
|
assetRes = await query(
|
|
|
|
|
'SELECT id, original_s3_key FROM assets WHERE filename = $1 LIMIT 1',
|
|
|
|
|
[edit.reelName]
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-07 21:58:19 -04:00
|
|
|
|
2026-05-28 14:08:49 -04:00
|
|
|
if (assetRes.rows.length === 0) {
|
|
|
|
|
throw new Error(`Asset not found for reel: ${edit.reelName}`);
|
|
|
|
|
}
|
|
|
|
|
sourceKey = assetRes.rows[0].original_s3_key;
|
2026-04-07 21:58:19 -04:00
|
|
|
}
|
|
|
|
|
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
const segmentInputPath = join(segmentsDir, `segment-${edit.editNumber}-src`);
|
2026-05-15 23:40:13 -04:00
|
|
|
const segmentOutputPath = join(segmentsDir, `segment-${edit.editNumber}.mov`);
|
2026-04-07 21:58:19 -04:00
|
|
|
|
2026-05-15 23:40:13 -04:00
|
|
|
console.log(`[conform] Downloading segment ${edit.editNumber} from S3 (${sourceKey})`);
|
2026-04-07 21:58:19 -04:00
|
|
|
await downloadFromS3(S3_BUCKET, sourceKey, segmentInputPath);
|
|
|
|
|
|
fix(worker): conform — 2-pass strategy (normalise on trim, demux on concat)
ffmpeg 8.x's concat filter kept dying with the opaque
[fc#0] Error sending frames to consumers: Invalid argument
even after we locked fps + sample rate + pixel format + SAR in the
filter graph. Mixed sources (AV1+H.264, 23.98+60 fps, 44100+48000 Hz,
tv-range+unspecified-range pixel format) just don't survive the
concat filter cleanly in this build.
Switch to the more reliable 2-pass pattern:
1. At the trim step, re-encode each segment to a uniform intermediate
spec: libx264 ultrafast, 1920x1080 (letterboxed), yuv420p,
seqFps target rate, 48kHz stereo AAC. Per-segment ffmpeg.
2. At the concat step, use the concat *demuxer*. Because every input
now matches exactly, the demuxer is well-behaved. Transcode the
concatenated stream to the final target codec (ProRes 422 HQ etc).
Costs an extra intermediate encode (libx264 ultrafast ≈ realtime on
this hardware) but eliminates the filter-graph fragility on mixed-
source timelines, which is the workload that actually matters.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:34:52 -04:00
|
|
|
// Trim + normalise in a single ffmpeg pass per segment. We re-encode
|
|
|
|
|
// here (libx264 ultrafast) so every segment lands at the same spec
|
|
|
|
|
// — same fps, resolution, pixel format, sample rate, channel layout
|
|
|
|
|
// — which lets the final concat-demuxer step run reliably even when
|
|
|
|
|
// the source clips are wildly different (mixed codecs / fps / sample
|
|
|
|
|
// rate). The double-encode (intermediate h264 → final ProRes) costs
|
|
|
|
|
// some CPU but avoids the concat filter's opaque "Invalid argument"
|
|
|
|
|
// failures with disparate sources.
|
|
|
|
|
console.log(`[conform] Trim + normalise ${edit.editNumber}: ${edit.sourceIn} → ${edit.sourceOut}`);
|
|
|
|
|
const segMs = await getMediaInfo(segmentInputPath);
|
|
|
|
|
const segFps = segMs.fps || 30;
|
|
|
|
|
const inSec = edit.sourceIn / segFps;
|
|
|
|
|
const durSec = (edit.sourceOut - edit.sourceIn) / segFps;
|
|
|
|
|
await runFFmpeg([
|
|
|
|
|
'-ss', String(inSec),
|
|
|
|
|
'-i', segmentInputPath,
|
|
|
|
|
'-t', String(durSec),
|
|
|
|
|
'-vf', `fps=${Math.round(seqFps) || 30},` +
|
|
|
|
|
`scale=1920:1080:force_original_aspect_ratio=decrease,` +
|
|
|
|
|
`pad=1920:1080:(ow-iw)/2:(oh-ih)/2,` +
|
|
|
|
|
`setsar=1,format=yuv420p`,
|
2026-05-28 15:37:35 -04:00
|
|
|
// ffmpeg 8.x dropped the `ocl=` shortcut on aresample. Use aformat
|
|
|
|
|
// for the channel layout assertion + auto-conversion; aresample
|
|
|
|
|
// just sets the rate.
|
|
|
|
|
'-af', 'aresample=48000,aformat=channel_layouts=stereo:sample_fmts=fltp',
|
fix(worker): conform — 2-pass strategy (normalise on trim, demux on concat)
ffmpeg 8.x's concat filter kept dying with the opaque
[fc#0] Error sending frames to consumers: Invalid argument
even after we locked fps + sample rate + pixel format + SAR in the
filter graph. Mixed sources (AV1+H.264, 23.98+60 fps, 44100+48000 Hz,
tv-range+unspecified-range pixel format) just don't survive the
concat filter cleanly in this build.
Switch to the more reliable 2-pass pattern:
1. At the trim step, re-encode each segment to a uniform intermediate
spec: libx264 ultrafast, 1920x1080 (letterboxed), yuv420p,
seqFps target rate, 48kHz stereo AAC. Per-segment ffmpeg.
2. At the concat step, use the concat *demuxer*. Because every input
now matches exactly, the demuxer is well-behaved. Transcode the
concatenated stream to the final target codec (ProRes 422 HQ etc).
Costs an extra intermediate encode (libx264 ultrafast ≈ realtime on
this hardware) but eliminates the filter-graph fragility on mixed-
source timelines, which is the workload that actually matters.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:34:52 -04:00
|
|
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '18',
|
|
|
|
|
'-pix_fmt', 'yuv420p',
|
|
|
|
|
'-c:a', 'aac', '-b:a', '320k', '-ar', '48000',
|
|
|
|
|
'-shortest',
|
|
|
|
|
'-y', segmentOutputPath,
|
|
|
|
|
]);
|
2026-04-07 21:58:19 -04:00
|
|
|
|
|
|
|
|
concatList.push(segmentOutputPath);
|
|
|
|
|
await unlink(segmentInputPath).catch(() => {});
|
|
|
|
|
processedEdits++;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 23:40:13 -04:00
|
|
|
await job.updateProgress(60);
|
|
|
|
|
console.log(`[conform] Writing concat list for ${concatList.length} segments`);
|
|
|
|
|
const concatContent = concatList.map(p => `file '${p}'`).join('\n');
|
2026-04-07 21:58:19 -04:00
|
|
|
await writeFile(segmentListPath, concatContent, 'utf-8');
|
|
|
|
|
|
2026-05-15 23:40:13 -04:00
|
|
|
await job.updateProgress(70);
|
2026-04-07 21:58:19 -04:00
|
|
|
console.log(`[conform] Concatenating segments for job ${jobId}`);
|
|
|
|
|
|
2026-05-28 14:32:49 -04:00
|
|
|
// Audio: be permissive. Anything that isn't an explicit 'none' should
|
|
|
|
|
// get encoded — the panel sends 'broadcast' (default), 'include' is the
|
|
|
|
|
// legacy value, and there's no reason to silently drop audio for any
|
|
|
|
|
// other label. 320k AAC is a safe broadcast-quality default in mp4.
|
|
|
|
|
const audioFlag = (audio === 'none' || audio === 'off')
|
|
|
|
|
? ['-an']
|
|
|
|
|
: ['-c:a', 'aac', '-b:a', '320k', '-ar', '48000'];
|
|
|
|
|
|
|
|
|
|
// Codec map. The panel sends 'prores_hq' / 'prores_4444' / 'h264' / 'h265'
|
|
|
|
|
// / 'dnxhr_hq'; old EDL callers send 'prores' / 'h265' / 'h264'. Match
|
|
|
|
|
// both. prores_ks profiles: 0=proxy 1=lt 2=std 3=hq 4=4444.
|
|
|
|
|
let videoCodec, profileFlag = [];
|
|
|
|
|
if (codec === 'prores_hq' || codec === 'prores') {
|
|
|
|
|
videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '3'];
|
|
|
|
|
} else if (codec === 'prores_4444') {
|
|
|
|
|
videoCodec = 'prores_ks'; profileFlag = ['-profile:v', '4'];
|
|
|
|
|
} else if (codec === 'h265' || codec === 'hevc') {
|
|
|
|
|
videoCodec = 'libx265';
|
|
|
|
|
} else if (codec === 'dnxhr_hq') {
|
|
|
|
|
videoCodec = 'dnxhd'; profileFlag = ['-profile:v', 'dnxhr_hq'];
|
|
|
|
|
} else {
|
|
|
|
|
videoCodec = 'libx264';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// prores_ks ignores -crf and uses -preset differently; libx264/x265 use
|
|
|
|
|
// crf-based quality. Branch the encode args.
|
|
|
|
|
const isProRes = videoCodec === 'prores_ks';
|
|
|
|
|
const qualityArgs = isProRes
|
|
|
|
|
? [] // ProRes profile already encodes the quality target
|
|
|
|
|
: [
|
|
|
|
|
'-preset', quality === 'high' ? 'slow' : quality === 'broadcast' ? 'veryslow' : 'fast',
|
|
|
|
|
'-crf', quality === 'broadcast' ? '18' : quality === 'high' ? '23' : '28',
|
|
|
|
|
];
|
|
|
|
|
|
fix(worker): conform — 2-pass strategy (normalise on trim, demux on concat)
ffmpeg 8.x's concat filter kept dying with the opaque
[fc#0] Error sending frames to consumers: Invalid argument
even after we locked fps + sample rate + pixel format + SAR in the
filter graph. Mixed sources (AV1+H.264, 23.98+60 fps, 44100+48000 Hz,
tv-range+unspecified-range pixel format) just don't survive the
concat filter cleanly in this build.
Switch to the more reliable 2-pass pattern:
1. At the trim step, re-encode each segment to a uniform intermediate
spec: libx264 ultrafast, 1920x1080 (letterboxed), yuv420p,
seqFps target rate, 48kHz stereo AAC. Per-segment ffmpeg.
2. At the concat step, use the concat *demuxer*. Because every input
now matches exactly, the demuxer is well-behaved. Transcode the
concatenated stream to the final target codec (ProRes 422 HQ etc).
Costs an extra intermediate encode (libx264 ultrafast ≈ realtime on
this hardware) but eliminates the filter-graph fragility on mixed-
source timelines, which is the workload that actually matters.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:34:52 -04:00
|
|
|
// Concat: every segment was normalised at trim time (uniform fps,
|
|
|
|
|
// resolution, pixel format, sample rate, stereo). The demuxer can
|
|
|
|
|
// stream-stitch them and we just need to transcode the result to the
|
|
|
|
|
// final target codec. This bypasses ffmpeg 8.x's brittle concat-
|
|
|
|
|
// filter path that was throwing
|
2026-05-28 15:21:23 -04:00
|
|
|
// [fc#0] Error sending frames to consumers: Invalid argument
|
fix(worker): conform — 2-pass strategy (normalise on trim, demux on concat)
ffmpeg 8.x's concat filter kept dying with the opaque
[fc#0] Error sending frames to consumers: Invalid argument
even after we locked fps + sample rate + pixel format + SAR in the
filter graph. Mixed sources (AV1+H.264, 23.98+60 fps, 44100+48000 Hz,
tv-range+unspecified-range pixel format) just don't survive the
concat filter cleanly in this build.
Switch to the more reliable 2-pass pattern:
1. At the trim step, re-encode each segment to a uniform intermediate
spec: libx264 ultrafast, 1920x1080 (letterboxed), yuv420p,
seqFps target rate, 48kHz stereo AAC. Per-segment ffmpeg.
2. At the concat step, use the concat *demuxer*. Because every input
now matches exactly, the demuxer is well-behaved. Transcode the
concatenated stream to the final target codec (ProRes 422 HQ etc).
Costs an extra intermediate encode (libx264 ultrafast ≈ realtime on
this hardware) but eliminates the filter-graph fragility on mixed-
source timelines, which is the workload that actually matters.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:34:52 -04:00
|
|
|
// on mixed-source timelines.
|
|
|
|
|
const encodeAudio = (audio === 'none' || audio === 'off')
|
|
|
|
|
? ['-an']
|
|
|
|
|
: ['-c:a', 'aac', '-b:a', '320k', '-ar', '48000'];
|
2026-05-28 15:04:55 -04:00
|
|
|
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
await runFFmpeg([
|
fix(worker): conform — 2-pass strategy (normalise on trim, demux on concat)
ffmpeg 8.x's concat filter kept dying with the opaque
[fc#0] Error sending frames to consumers: Invalid argument
even after we locked fps + sample rate + pixel format + SAR in the
filter graph. Mixed sources (AV1+H.264, 23.98+60 fps, 44100+48000 Hz,
tv-range+unspecified-range pixel format) just don't survive the
concat filter cleanly in this build.
Switch to the more reliable 2-pass pattern:
1. At the trim step, re-encode each segment to a uniform intermediate
spec: libx264 ultrafast, 1920x1080 (letterboxed), yuv420p,
seqFps target rate, 48kHz stereo AAC. Per-segment ffmpeg.
2. At the concat step, use the concat *demuxer*. Because every input
now matches exactly, the demuxer is well-behaved. Transcode the
concatenated stream to the final target codec (ProRes 422 HQ etc).
Costs an extra intermediate encode (libx264 ultrafast ≈ realtime on
this hardware) but eliminates the filter-graph fragility on mixed-
source timelines, which is the workload that actually matters.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:34:52 -04:00
|
|
|
'-f', 'concat',
|
|
|
|
|
'-safe', '0',
|
|
|
|
|
'-i', segmentListPath,
|
2026-05-28 14:32:49 -04:00
|
|
|
'-c:v', videoCodec,
|
|
|
|
|
...profileFlag,
|
|
|
|
|
...qualityArgs,
|
2026-05-28 15:04:55 -04:00
|
|
|
...encodeAudio,
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
'-y', outputPath,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-05-15 23:40:13 -04:00
|
|
|
await job.updateProgress(85);
|
2026-05-28 15:40:53 -04:00
|
|
|
const outputKey = `jobs/${jobId}/conformed.${outputExt}`;
|
2026-05-15 23:40:13 -04:00
|
|
|
console.log(`[conform] Uploading output to ${outputKey}`);
|
2026-04-07 21:58:19 -04:00
|
|
|
await uploadToS3(S3_BUCKET, outputKey, outputPath);
|
|
|
|
|
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
// Register the conformed output as a new asset
|
|
|
|
|
const assetRes = await query(
|
|
|
|
|
`INSERT INTO assets (project_id, filename, display_name, media_type, status, original_s3_key, codec, resolution, fps, duration_ms, conform_source_sequence_id)
|
|
|
|
|
VALUES ($1, $2, $3, 'video', 'ready', $4, $5, $6, $7, $8, $9) RETURNING id`,
|
|
|
|
|
[
|
|
|
|
|
projectId || null,
|
2026-05-28 15:40:53 -04:00
|
|
|
`conformed-${seqName.replace(/[^a-z0-9]/gi, '_')}.${outputExt}`,
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
`Conformed: ${seqName}`,
|
|
|
|
|
outputKey,
|
2026-05-28 14:32:49 -04:00
|
|
|
// Normalise the panel's codec id into the canonical name we store on
|
|
|
|
|
// the asset row. Keep aligned with the encode branch above.
|
|
|
|
|
(codec === 'prores_hq' || codec === 'prores_4444' || codec === 'prores') ? 'prores'
|
|
|
|
|
: (codec === 'h265' || codec === 'hevc') ? 'hevc'
|
|
|
|
|
: (codec === 'dnxhr_hq') ? 'dnxhd'
|
|
|
|
|
: 'h264',
|
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
|
|
|
resolution !== 'match' ? resolution : '1920x1080',
|
|
|
|
|
seqFps,
|
|
|
|
|
null,
|
|
|
|
|
job.data.sequenceId || null,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
2026-05-28 15:49:01 -04:00
|
|
|
const newAssetId = assetRes.rows[0].id;
|
|
|
|
|
|
|
|
|
|
// Queue a proxy build so the library has a browser-playable H.264 file.
|
|
|
|
|
// ProRes / DNxHR masters don't decode in HTML5 video; without this step
|
|
|
|
|
// the asset shows MEDIA_ERR_SRC_NOT_SUPPORTED in the player. Mirror the
|
|
|
|
|
// ingest pipeline's pattern (services/mam-api/src/routes/assets.js).
|
|
|
|
|
try {
|
|
|
|
|
const generatedProxyKey = `proxies/${newAssetId}.mp4`;
|
|
|
|
|
await proxyQueue.add('generate', {
|
|
|
|
|
assetId: newAssetId,
|
|
|
|
|
inputKey: outputKey,
|
|
|
|
|
outputKey: generatedProxyKey,
|
|
|
|
|
});
|
|
|
|
|
console.log(`[conform] queued proxy build for ${newAssetId}`);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Don't fail the conform job if the proxy queue is unreachable —
|
|
|
|
|
// the asset still exists, an operator can retrigger the proxy.
|
|
|
|
|
console.warn(`[conform] failed to queue proxy for ${newAssetId}: ${e.message}`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 23:40:13 -04:00
|
|
|
await job.updateProgress(100);
|
2026-05-28 15:49:01 -04:00
|
|
|
console.log(`[conform] Job ${jobId} complete → asset ${newAssetId}`);
|
2026-05-18 23:28:13 -04:00
|
|
|
|
2026-05-28 15:49:01 -04:00
|
|
|
return { jobId, outputKey, assetId: newAssetId };
|
2026-05-15 23:40:13 -04:00
|
|
|
|
2026-04-07 21:58:19 -04:00
|
|
|
} catch (error) {
|
2026-05-15 23:40:13 -04:00
|
|
|
console.error(`[conform] Error in job ${jobId}:`, error);
|
2026-05-26 07:35:13 -04:00
|
|
|
// BUG FIX #1: Mark the output asset (if any) as 'error' so the UI doesn't
|
|
|
|
|
// show a perpetually-spinning 'processing' state when the conform fails.
|
|
|
|
|
// We don't have an assetId until the INSERT succeeds, so target by job key.
|
|
|
|
|
await query(
|
|
|
|
|
`UPDATE assets
|
|
|
|
|
SET status = 'error', updated_at = NOW()
|
|
|
|
|
WHERE original_s3_key = $1`,
|
|
|
|
|
[`jobs/${jobId}/conformed.mp4`]
|
|
|
|
|
).catch(e => console.error('[conform] Failed to mark asset error:', e.message));
|
2026-04-07 21:58:19 -04:00
|
|
|
throw error;
|
|
|
|
|
} finally {
|
2026-05-15 23:40:13 -04:00
|
|
|
await Promise.all([
|
|
|
|
|
unlink(segmentListPath).catch(() => {}),
|
|
|
|
|
unlink(outputPath).catch(() => {}),
|
2026-05-19 23:06:54 -04:00
|
|
|
rm(segmentsDir, { recursive: true, force: true }).catch(() => {}),
|
2026-05-15 23:40:13 -04:00
|
|
|
]);
|
2026-04-07 21:58:19 -04:00
|
|
|
}
|
|
|
|
|
};
|