PageSourceSearch

https://teslacamviewer.com/js/multiCameraExportDecoder.js?v=3

js teslacamviewer.com collected 2026-09-25 22:34:34 UTC 15,230 bytes, 336 lines download raw bytes

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.