fix(capture): restore frame counting + growing MXF for Premiere

The previous sed/python in-place edits on the node broke capture: the
hires stderr parser was written with literal 0x08 BACKSPACE bytes instead
of regex word boundaries, so it never matched ffmpeg output.
framesReceived stayed 0, the shutdown handler saw "no frames" and marked
every asset as an error even though video was captured. The ffmpeg base
args had also been changed to -progress pipe:2, whose key=value output
puts frame= and fps= on separate lines and does not match a combined
regex.

Fixes:
- Parser: single robust regex matching ffmpeg's classic -stats line
  (frame= and fps= together). No backspace bytes, no word boundaries.
- ffmpeg base args back to -stats (drop -progress pipe:2).

Growing-file (Premiere edit-while-record), per bmx thread 87ac5750 and
Drastic/Softron edit-while-ingest docs:
- raw2bmx clip type op1a -> rdd9 (Sony XDCAM / RDD-9, the flavour Premiere
  reads while growing) with --index-follows so the IndexTableSegment is
  written in the same partition as the essence it indexes (lets a reader
  re-scanning body partitions seek toward the record head). NOT --avid-gf
  (Avid OP-Atom, Media-Composer-only, needs a companion AAF).
- dur-patch.py: overwrite header Duration=-1 to 0 immediately at
  clip-open (Premiere rejects -1 on import), then track the live frame
  count every 3s from the last body partition IndexTableSegment. Shipped
  as services/capture/dur-patch.py (/app/dur-patch.py in the image).

Deployed to wild-dragon-capture:latest on zampp2 via overlay build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-06-01 09:00:23 -04:00
parent 1750298bb8
commit e3be8745d3
2 changed files with 107 additions and 5 deletions

View file

@ -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)<bs+bl+48:break
ibc=struct.unpack(">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 p2<len(sd)-3:
t=struct.unpack(">H",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)

View file

@ -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=$?