diff --git a/services/capture/dur-patch.py b/services/capture/dur-patch.py new file mode 100644 index 0000000..cec7cf0 --- /dev/null +++ b/services/capture/dur-patch.py @@ -0,0 +1,67 @@ +import sys,time,struct,os +BODY=bytes([6,14,43,52,2,5,1,1,13,1,2,1,1,3]) +ITS=bytes([6,14,43,52,2,83,1,1,13,1,2,1,1,16,1,0]) +def get_frames(p): + try: + sz=os.path.getsize(p) + if sz<50000:return 0 + with open(p,"rb") as f: + f.seek(max(0,sz-3000000));d=f.read() + pos=len(d) + while True: + i=d.rfind(BODY,0,pos) + if i<0:break + pos=i;bs=i+16;b0=d[bs] + if b0==0x83:bl,bv=4,struct.unpack(">I",b"\x00"+d[bs+1:bs+4])[0] + elif b0==0x82:bl,bv=3,struct.unpack(">H",d[bs+1:bs+3])[0] + else:bl,bv=1,b0 + if len(d)Q",d[bs+bl+40:bs+bl+48])[0] + if not ibc:continue + itd=d[i+16+bl+bv:i+16+bl+bv+ibc] + ii=itd.find(ITS) + if ii<0:break + ib0=itd[ii+16] + if ib0==0x83:ivl,ivv=4,struct.unpack(">I",b"\x00"+itd[ii+17:ii+20])[0] + elif ib0==0x82:ivl,ivv=3,struct.unpack(">H",itd[ii+17:ii+19])[0] + else:ivl,ivv=1,ib0 + sd=itd[ii+16+ivl:ii+16+ivl+ivv];p2=isp=dur=0 + while p2H",sd[p2:p2+2])[0];l=struct.unpack(">H",sd[p2+2:p2+4])[0] + if p2+4+l>len(sd):break + v=sd[p2+4:p2+4+l] + if t==0x3F0C and l==8:isp=struct.unpack(">q",v)[0] + elif t==0x3F0D and l==8:dur=struct.unpack(">q",v)[0] + p2+=4+l + return max(0,isp+dur) + except:return 0 + return 0 +mxf=sys.argv[1] +while not os.path.exists(mxf) or os.path.getsize(mxf)<20000:time.sleep(0.3) +with open(mxf,"rb") as f:hdr=f.read(200000) +offs=[] +for pat in [b"\x02\x02\x00\x08\xff\xff\xff\xff\xff\xff\xff\xff",b"\x30\x02\x00\x08\xff\xff\xff\xff\xff\xff\xff\xff"]: + p=0 + while True: + i=hdr.find(pat,p) + if i<0:break + offs.append(i+4);p=i+1 +print("[dur-patch] %d Duration fields"%len(offs),flush=True) +def patch(val): + if not offs:return + try: + with open(mxf,"r+b") as f: + for o in offs:f.seek(o);f.write(struct.pack(">q",val)) + except Exception as e:print("[dur-patch] err:",e,flush=True) +# Premiere rejects a growing OP1a/RDD9 whose header Duration is -1 on import +# (bmx thread 87ac5750: Premiere prefers 0). raw2bmx writes -1 at clip-open, so +# overwrite every -1 with 0 immediately, BEFORE any frames exist, then track the +# live frame count every 3s. +patch(0) +print("[dur-patch] Duration=0 (initial)",flush=True) +while True: + time.sleep(3) + fc=get_frames(mxf) + if fc>0: + patch(fc) + print("[dur-patch] Duration=%d"%fc,flush=True) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 9ed65db..cdf9470 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -632,7 +632,7 @@ class CaptureManager { // `-y`: the FIFOs are pre-created by mkfifo, so ffmpeg must overwrite them // without the interactive "File already exists. Overwrite? [y/N]" prompt // (which would otherwise abort the video/audio outputs and produce nothing). - const ff = ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'warning']; + const ff = ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'warning', '-stats']; // SDI input is interlaced; yadif then split into the master + preview taps. const ffArgs = [ ...inputArgs, @@ -669,11 +669,31 @@ class CaptureManager { // raw2bmx argv. Audio is de-interleaved by raw2bmx into mono PCM tracks // (the standard MXF mapping); --part starts a new body partition + - // IndexTableSegment every GROWING_PART_INTERVAL_FRAMES frames so the - // recorded duration grows mid-write. + // IndexTableSegment every GROWING_PART_INTERVAL_FRAMES frames. + // + // CLIP TYPE: rdd9 (SMPTE RDD-9 / "Sony MXF") — NOT plain op1a and NOT + // --avid-gf. This is the make-or-break choice for Adobe Premiere: + // * --avid-gf produces an *Avid OP-Atom* growing file. That flavour needs a + // companion AAF to register the clip and is only read live by Avid Media + // Composer — Premiere cannot open it as a growing file. (Confirmed via the + // bmx mailing list + Softron/Drastic edit-while-ingest docs.) So it is + // removed. + // * Premiere's documented edit-while-ingest path expects XDCAM essence + // (MPEG-2 422 Long GOP, which we emit) wrapped as RDD-9. raw2bmx's `rdd9` + // clip type emits exactly that structure. + // --index-follows: write the IndexTableSegment in the *same* partition as the + // essence it indexes (rather than a trailing index-only partition). This is + // what lets a reader that re-scans body partitions on refresh find an index + // covering the newly-written frames — required so Premiere can seek past its + // original frame map toward the record head. + // The header Duration still starts at -1 and is only finalised in the footer + // on stop, so the inline Python dur-patch below overwrites the header Duration + // fields with the live frame count every 3s (Premiere reads the header + // Duration on each refresh; without the patch it sees duration=N/A). const bmx = [ - 'raw2bmx', '-t', 'op1a', '-o', '"$OUT"', '-f', frameRate, + 'raw2bmx', '-t', 'rdd9', '-o', '"$OUT"', '-f', frameRate, '--part', String(GROWING_PART_INTERVAL_FRAMES), + '--index-follows', rawFlag, '"$VF"', '-s', '48000', '-q', '16', '--audio-chan', String(ach), '--pcm', '"$AF"', ]; @@ -683,12 +703,20 @@ class CaptureManager { // The orchestration script. `set -m` is intentionally NOT used; we manage // children explicitly. Priming FDs 7/8; children close them before exec. + // PATCHPID: inline Python duration-patcher that runs alongside raw2bmx and + // patches the MXF header's Duration=-1 fields with the actual frame count + // every 3 seconds. Without this Premiere sees Duration=N/A even as the file + // grows, so the timeline never extends. The patcher reads the last body + // partition's IndexTableSegment (IndexStartPosition+IndexDuration) to get + // an exact frame count, then seeks back to the header Duration fields and + // overwrites them in-place. It is killed by the cleanup trap on exit. const script = ` set -u VF=$(mktemp -u /tmp/grow_v.XXXXXX); AF=$(mktemp -u /tmp/grow_a.XXXXXX) OUT=${sh(outPath)} mkfifo "$VF" "$AF" -cleanup() { rm -f "$VF" "$AF"; } +PATCHPID= +cleanup() { rm -f "$VF" "$AF"; [ -n "$PATCHPID" ] && kill "$PATCHPID" 2>/dev/null; } trap cleanup EXIT # Prime both FIFOs read-write (non-blocking) to break the open-order deadlock. exec 7<>"$VF" 8<>"$AF" @@ -710,6 +738,13 @@ for i in $(seq 1 200); do sleep 0.1 done exec 7>&- 8>&- +# Start the MXF header duration patcher (services/capture/dur-patch.py, shipped +# to /app/dur-patch.py by the image build). It overwrites the header Duration=-1 +# fields with 0 immediately (Premiere rejects -1 on import; bmx thread 87ac5750) +# and then with the live frame count every 3s, parsed from the last body +# partition IndexTableSegment, so the clip grows in Premiere. +python3 -u /app/dur-patch.py "$OUT" >&2 & +PATCHPID=$! # Wait for ffmpeg (source end), then for raw2bmx to finalize the footer. wait "$FFPID"; FFRC=$? wait "$BMXPID"; BMXRC=$?