import { join } from 'path'; import { unlink, writeFile, mkdir, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { Queue } from 'bullmq'; import { query } from '../db/client.js'; import { downloadFromS3, uploadToS3 } from '../s3/client.js'; import { trimSegment, concatSegments, runFFmpeg, getMediaInfo } from '../ffmpeg/executor.js'; import { parseEDL } from '../edl/parser.js'; import { XMLParser } from 'fast-xml-parser'; const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon'; // 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'), }); 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) { const trackNum = parseInt(track?.['@_currentExplodedTrackIndex'] || 0, 10); 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; } export const conformWorker = async (job) => { const { edl, fcpXml, projectId, sequenceId, sequenceName, frameRate, codec, quality, resolution, audio } = job.data; const jobId = job.id; const tmpDir = tmpdir(); const segmentsDir = join(tmpDir, `segments-${jobId}`); const segmentListPath = join(tmpDir, `segments-${jobId}.txt`); // 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}`); try { let edits = []; let seqName = sequenceName || 'Conformed'; let seqFps = parseFloat(frameRate) || 29.97; // ── 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-" // 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) { 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'); } await mkdir(segmentsDir, { recursive: true }); let processedEdits = 0; const concatList = []; for (const edit of edits) { await job.updateProgress(Math.min(5 + (processedEdits / edits.length) * 50, 55)); console.log(`[conform] Processing edit ${edit.editNumber}: ${edit.reelName}`); // 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 { assetRes = await query( 'SELECT id, original_s3_key FROM assets WHERE filename = $1 LIMIT 1', [edit.reelName] ); } if (assetRes.rows.length === 0) { throw new Error(`Asset not found for reel: ${edit.reelName}`); } sourceKey = assetRes.rows[0].original_s3_key; } const segmentInputPath = join(segmentsDir, `segment-${edit.editNumber}-src`); const segmentOutputPath = join(segmentsDir, `segment-${edit.editNumber}.mov`); console.log(`[conform] Downloading segment ${edit.editNumber} from S3 (${sourceKey})`); await downloadFromS3(S3_BUCKET, sourceKey, segmentInputPath); // 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`, // 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', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '18', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '320k', '-ar', '48000', '-shortest', '-y', segmentOutputPath, ]); concatList.push(segmentOutputPath); await unlink(segmentInputPath).catch(() => {}); processedEdits++; } await job.updateProgress(60); console.log(`[conform] Writing concat list for ${concatList.length} segments`); const concatContent = concatList.map(p => `file '${p}'`).join('\n'); await writeFile(segmentListPath, concatContent, 'utf-8'); await job.updateProgress(70); console.log(`[conform] Concatenating segments for job ${jobId}`); // 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', ]; // 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 // [fc#0] Error sending frames to consumers: Invalid argument // on mixed-source timelines. const encodeAudio = (audio === 'none' || audio === 'off') ? ['-an'] : ['-c:a', 'aac', '-b:a', '320k', '-ar', '48000']; await runFFmpeg([ '-f', 'concat', '-safe', '0', '-i', segmentListPath, '-c:v', videoCodec, ...profileFlag, ...qualityArgs, ...encodeAudio, '-y', outputPath, ]); await job.updateProgress(85); const outputKey = `jobs/${jobId}/conformed.${outputExt}`; console.log(`[conform] Uploading output to ${outputKey}`); await uploadToS3(S3_BUCKET, outputKey, outputPath); // 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, `conformed-${seqName.replace(/[^a-z0-9]/gi, '_')}.${outputExt}`, `Conformed: ${seqName}`, outputKey, // 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', resolution !== 'match' ? resolution : '1920x1080', seqFps, null, job.data.sequenceId || null, ] ); 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}`); } await job.updateProgress(100); console.log(`[conform] Job ${jobId} complete → asset ${newAssetId}`); return { jobId, outputKey, assetId: newAssetId }; } catch (error) { console.error(`[conform] Error in job ${jobId}:`, error); // 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)); throw error; } finally { await Promise.all([ unlink(segmentListPath).catch(() => {}), unlink(outputPath).catch(() => {}), rm(segmentsDir, { recursive: true, force: true }).catch(() => {}), ]); } };