1/** 2 * MultiCameraExportDecoder â coordinates per-camera WebCodecs decoders across 3 * an event's clip boundaries, producing synchronized frame sets for the 4 * export pipeline. 5 * 6 * Problem this solves 7 * ------------------- 8 * The legacy export Phase-1 loop (videoExport.js) seeks live HTML5 <video> 9 * elements to each output frame time, polls readyState, and draws to canvas. 10 * For a 60s event at 30fps that's 1800 seeks à 4+ cameras, each bounded by 11 * browser video-decode real-time and disk I/O â ~10à slower than realtime. 12 * 13 * Approach 14 * -------- 15 * Per camera, we demux each clip's mp4 with FastClipDecoder and stream 16 * VideoFrames out sequentially (no seeking). For each output frame time we 17 * pull the latest-available frame from each camera and return them together. 18 * 19 * Frames are produced by pull-based async iteration so memory stays bounded: 20 * the caller awaits nextOutputFrame(), we decode until at least one frame 21 * per camera has passed the requested time, yield them, then continue. 22 * 23 * Clip transitions are handled transparently â when a camera's current clip 24 * ends, we tear down that clip's decoder and init the next one. 25 * 26 * Usage 27 * ----- 28 * const dec = new window.MultiCameraExportDecoder(); 29 * await dec.init({ event, visibleCameras, clipDurations, 30 * startEventSec, endEventSec, fps }); 31 * while (dec.hasMore()) { 32 * const { eventTime, frames } = await dec.nextOutputFrame(); 33 * // frames is Map<cameraName, ImageBitmap>. Draw, then close each. 34 * } 35 * await dec.close(); 36 */ 37(function () { 38 'use strict'; 39 40 const DECODE_CHUNK_SEC = 2.0; // how far ahead each camera decodes before yielding 41 const FRAME_TOLERANCE_SEC = 0.05; // snap-to-frame tolerance when matching output time 42 43 class CameraPipeline { 44 /** 45 * One camera's view of the event â an ordered list of clips plus the 46 * offset within the event where each clip starts. Maintains a pending 47 * queue of decoded frames and a pointer to the current clip decoder. 48 */ 49 constructor(cameraName, clips, startEventSec, endEventSec) { 50 this.name = cameraName; 51 // Each entry: { fileHandle, fileName, eventStartSec, eventEndSec, duration } 52 this.clips = clips; 53 this.startEventSec = startEventSec; 54 this.endEventSec = endEventSec; 55 56 // Pending frames to consume; each entry: { bitmap, eventTimeSec } 57 this.queue = []; 58 this.currentClipIdx = -1; 59 this.currentDecoder = null; 60 this.done = false; 61 } 62 63 async _advanceToClip(idx) { 64 if (this.currentDecoder) { 65 try { this.currentDecoder.close(); } catch {} 66 this.currentDecoder = null; 67 } 68 this.currentClipIdx = idx; 69 if (idx >= this.clips.length) { this.done = true; return; } 70 const clip = this.clips[idx]; 71 const file = await clip.fileHandle.getFile(); 72 const dec = new window.FastClipDecoder(); 73 await dec.init(file); 74 if (!dec.ready) { 75 // Init failed; skip this clip so we don't stall the whole export. 76 this.currentDecoder = null; 77 return; 78 } 79 this.currentDecoder = dec; 80 } 81 82 /** 83 * Ensure we have enough decoded frames queued to satisfy requests up 84 * to targetEventSec. Returns when queue covers targetEventSec OR we 85 * reach end-of-event. 86 * 87 * Edge cases guarded against: 88 * - On the LAST clip and content exhausted â must set done, not spin 89 * - decodeSequence returned zero frames (clip broken) â must advance 90 * - targetEventSec past endEventSec â clamp so we don't chase nothing 91 */ 92 async ensureDecodedUpTo(targetEventSec) { 93 const effectiveTarget = Math.min(targetEventSec, this.endEventSec); 94 let safety = 0; 95 while (!this.done) { 96 if (++safety > 10000) { 97 console.warn(`[CameraPipeline:${this.name}] safety break after ${safety} iterations â forcing done`); 98 this.done = true; 99 return; 100 } 101 102 const queueLastTs = this.queue.length 103 ? this.queue[this.queue.length - 1].eventTimeSec 104 : this.startEventSec - 1; // sentinel â always < effectiveTarget 105 106 // Queue already satisfies the target 107 if (this.queue.length > 0 && queueLastTs >= effectiveTarget) return; 108 109 // Do we need to advance to a new clip? 110 const outOfRange = this.currentClipIdx < 0 || this.currentClipIdx >= this.clips.length; 111 const curClipExhausted = !outOfRange && 112 this.clips[this.currentClipIdx].eventEndSec <= queueLastTs + FRAME_TOLERANCE_SEC; 113 const needAdvance = outOfRange || !this.currentDecoder || curClipExhausted; 114 115 if (needAdvance) { 116 await this._advanceToClip(this.currentClipIdx + 1); 117 if (this.done) return; 118 if (!this.currentDecoder) { 119 // This clip failed to init; try the next one. If we've 120 // run out of clips, _advanceToClip set done and we 121 // exit on the next iteration's while check. 122 continue; 123 } 124 } 125 126 const clip = this.clips[this.currentClipIdx]; 127 const clipStartEvent = clip.eventStartSec; 128 const clipEndEvent = clip.eventEndSec; 129 const windowEnd = Math.min(clipEndEvent, effectiveTarget + DECODE_CHUNK_SEC); 130 const windowStart = Math.max(clipStartEvent, 131 this.queue.length ? queueLastTs + 1e-6 : this.startEventSec); 132 const clipLocalStart = Math.max(0, windowStart - clipStartEvent); 133 const clipLocalEnd = Math.max(0, windowEnd - clipStartEvent); 134 135 if (clipLocalEnd <= clipLocalStart) { 136 // Nothing more to decode from this clip 137 try { this.currentDecoder?.close(); } catch {} 138 this.currentDecoder = null; 139 if (this.currentClipIdx >= this.clips.length - 1) { 140 this.done = true; 141 return; 142 } 143 continue; 144 } 145 146 let framesThisPass = 0; 147 try { 148 await this.currentDecoder.decodeSequence(clipLocalStart, clipLocalEnd, 149 (bitmap, tsSec) => { 150 const eventTimeSec = clipStartEvent + tsSec; 151 if (eventTimeSec >= this.endEventSec) { 152 try { bitmap.close?.(); } catch {} 153 return; 154 } 155 this.queue.push({ bitmap, eventTimeSec }); 156 framesThisPass++; 157 }); 158 } catch (err) { 159 // Don't fail the whole export over a single broken clip. 160 // Log, tear down this clip's decoder, move on. The output 161 // frames in the affected range will show as whatever the 162 // previous decoded frame was (or a stale one from a peer 163 // camera), which is preferable to zero-frame output. 164 console.warn(`[CameraPipeline:${this.name}] decode failed for ${this.clips[this.currentClipIdx]?.fileName}:`, err?.message || err); 165 try { this.currentDecoder?.close(); } catch {} 166 this.currentDecoder = null; 167 if (this.currentClipIdx >= this.clips.length - 1) { 168 this.done = true; 169 return; 170 } 171 continue; 172 } 173 174 // If decodeSequence produced nothing, this clip can't contribute 175 // more â advance so we don't spin on an empty window. 176 if (framesThisPass === 0) { 177 try { this.currentDecoder?.close(); } catch {} 178 this.currentDecoder = null; 179 if (this.currentClipIdx >= this.clips.length - 1) { 180 this.done = true; 181 return; 182 } 183 continue; 184 } 185 186 // If we consumed all the way to the clip's end, tear down its 187 // decoder so the next iteration advances. 188 if (windowEnd >= clipEndEvent - 1e-6) { 189 try { this.currentDecoder?.close(); } catch {} 190 this.currentDecoder = null; 191 if (this.currentClipIdx >= this.clips.length - 1) { 192 this.done = true; 193 return; 194 } 195 } 196 } 197 } 198 199 /** Return the frame whose timestamp is closest to (but not after) requestedEventSec.
200 * Drops queue entries older than that so memory stays bounded. */ 201 takeFrameAt(requestedEventSec) { 202 // Drop frames strictly older than requested - 1 tick of tolerance 203 while (this.queue.length > 1 && this.queue[1].eventTimeSec <= requestedEventSec + FRAME_TOLERANCE_SEC) { 204 const stale = this.queue.shift(); 205 try { stale.bitmap.close?.(); } catch {} 206 } 207 if (this.queue.length === 0) return null; 208 const head = this.queue[0]; 209 // Give the head bitmap to the caller, reserve it from our queue 210 // (we won't consume it again â but we don't close it either; caller owns it). 211 // A single frame may actually be used for multiple output frames if the 212 // clip's source fps < output fps, so we CLONE the bitmap rather than hand 213 // over ownership. 214 return { bitmap: head.bitmap, eventTimeSec: head.eventTimeSec }; 215 } 216 217 async close() { 218 if (this.currentDecoder) { 219 try { this.currentDecoder.close(); } catch {} 220 this.currentDecoder = null; 221 } 222 for (const { bitmap } of this.queue) { 223 try { bitmap.close?.(); } catch {} 224 } 225 this.queue = []; 226 } 227 } 228 229 class MultiCameraExportDecoder { 230 constructor() { 231 this._cameras = new Map(); // cameraName -> CameraPipeline 232 this._startEventSec = 0; 233 this._endEventSec = 0; 234 this._fps = 30; 235 this._frameIntervalSec = 1 / 30; 236 this._currentOutputFrame = 0; 237 this._totalOutputFrames = 0; 238 } 239 240 /** 241 * @param {Object} opts 242 * @param {Object} opts.event â app event object (has clipGroups) 243 * @param {string[]} opts.visibleCameras â e.g. ['front','back','left_repeater','right_repeater'] 244 * @param {number[]} opts.clipDurations â per-clip duration in seconds (from videoPlayer cache) 245 * @param {number} opts.startEventSec 246 * @param {number} opts.endEventSec 247 * @param {number} opts.fps 248 */ 249 async init({ event, visibleCameras, clipDurations, startEventSec, endEventSec, fps }) { 250 this._startEventSec = startEventSec; 251 this._endEventSec = endEventSec; 252 this._fps = fps; 253 this._frameIntervalSec = 1 / fps; 254 this._totalOutputFrames = Math.max(1, Math.floor((endEventSec - startEventSec) * fps)); 255 256 // Build per-camera clip list with event-time spans. 257 for (const cam of visibleCameras) { 258 const clips = []; 259 let accum = 0; 260 for (let i = 0; i < event.clipGroups.length; i++) { 261 const cg = event.clipGroups[i]; 262 const duration = clipDurations?.[i] || 60; 263 const clip = cg?.clips?.[cam]; 264 const spanStart = accum; 265 const spanEnd = accum + duration; 266 accum = spanEnd; 267 if (!clip?.fileHandle) continue; // this camera didn't record this clip 268 // Skip clips entirely outside our range 269 if (spanEnd <= startEventSec || spanStart >= endEventSec) continue; 270 clips.push({ 271 fileHandle: clip.fileHandle, 272 fileName: clip.fileName, 273 eventStartSec: spanStart, 274 eventEndSec: spanEnd, 275 duration 276 }); 277 } 278 if (clips.length === 0) continue; 279 this._cameras.set(cam, new CameraPipeline(cam, clips, startEventSec, endEventSec)); 280 } 281 } 282 283 hasMore() { 284 return this._currentOutputFrame < this._totalOutputFrames; 285 } 286 287 /** 288 * Pull the next output frame â one composited-input worth of data, one 289 * per visible camera. Returns: 290 * { eventTime: number, frames: Map<cameraName, ImageBitmap|null> } 291 * 292 * The bitmaps are NOT owned by the caller â they belong to the pipeline 293 * and may be reused across consecutive output frames (since decoder 294 * fps is usually lower than output fps). The caller must NOT close 295 * them; closing happens in close(). 296 */ 297 async nextOutputFrame() { 298 if (!this.hasMore()) throw new Error('no more frames'); 299 const eventTime = this._startEventSec + (this._currentOutputFrame * this._frameIntervalSec); 300 301 // Pull from each camera in parallel 302 const frames = new Map(); 303 await Promise.all(Array.from(this._cameras.entries()).map(async ([name, pipe]) => { 304 await pipe.ensureDecodedUpTo(eventTime + this._frameIntervalSec); 305 const f = pipe.takeFrameAt(eventTime); 306 frames.set(name, f ? f.bitmap : null); 307 })); 308 309 this._currentOutputFrame++; 310 return { eventTime, frames }; 311 } 312 313 getProgress() { 314 return { 315 done: this._currentOutputFrame, 316 total: this._totalOutputFrames 317 }; 318 } 319 320 async close() { 321 await Promise.all(Array.from(this._cameras.values()).map(p => p.close())); 322 this._cameras.clear(); 323 } 324 325 /** Capability check â true if the full fast path is available. */ 326 static isSupported() { 327 return typeof window.VideoDecoder !== 'undefined' 328 && typeof window.EncodedVideoChunk !== 'undefined' 329 && typeof window.MP4Box !== 'undefined' 330 && typeof window.FastClipDecoder !== 'undefined' 331 && window.FastClipDecoder.WEBCODECS_AVAILABLE === true; 332 } 333 } 334 335 window.MultiCameraExportDecoder = MultiCameraExportDecoder; 336})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.