<template>
    <div class="camera-scanner">
        <p v-if="!cameraSupported" class="text-muted">
            This browser has no camera API available.
        </p>
        <template v-else>
            <p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
            <div class="video-wrap">
                <video ref="video" autoplay muted playsinline></video>
                <canvas ref="overlay"></canvas>
            </div>
            <div class="row g-2 align-items-end mb-3">
                <div class="col-auto flex-grow-1">
                    <select class="form-control" v-model="selectedCameraId">
                        <option value="">Auto (prefer recent, then rear camera)</option>
                        <option v-for="(c, i) in cameras" :key="c.deviceId"
                                :value="c.deviceId">
                            {{ cleanCameraLabel(c.label) || ('Camera ' + (i + 1)) }}
                        </option>
                    </select>
                </div>
                <div v-if="cameraRunning" class="col-auto">
                    <button class="btn btn-outline-secondary" @click="stopCamera">
                        <b-icon-stop-fill class="me-1"></b-icon-stop-fill>
                        Stop
                    </button>
                </div>
            </div>
        </template>
    </div>
</template>

<script>
import * as BIcons from "bootstrap-icons-vue";
import {loadAnyDCode, toGrayFrame} from "../../vendor/anyd-qr.js";
// Same file as above, but as a plain URL (Vite's `?url` suffix skips bundling it into this
// module) - the decode worker built in createDecodeWorker() below needs a fetchable/importable
// URL for its own, independent `import()`, since a worker has no access to this module's own
// import bindings.
import anydUrl from "../../vendor/anyd-qr.js?url";
import cameraManager from "@/cameraManager.js";

// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
// the code has moved out of frame.
const OVERLAY_CLEAR_MS = 2000;

// Capture-loop tuning, matching prototypes/anydcode-wasm's advanced camera demo's own
// defaults/findings (see its parameter-sweep-findings.md) but at a rate suited to a
// battery-conscious inventory-scanning UI rather than that demo's max-responsiveness dial.
const CAPTURE_FPS = 4;
const CAPTURE_DOWNSCALE = 2;

// Milliseconds to suppress re-emitting a symbology+text pair already reported - independent of
// (and shorter than) COOLDOWN_MS below, since it throttles noisy duplicate "scan" events even
// on ticks where a candidate did get decoded (e.g. because the worker was otherwise idle).
const EMIT_DEDUPE_MS = 800;

// Location-based cooldown: skip re-decoding a candidate whose box overlaps a recent successful
// decode while the worker has other work pending, so a code sitting still in frame doesn't keep
// re-paying decode2d's cost every tick. Ported the IoU-overlap idea (not the code) from
// prototypes/anydcode-wasm/packages/qr-wasm/advanced_demo/pipeline.js's createCooldownTracker.
const COOLDOWN_MS = 1500;
const COOLDOWN_IOU_THRESHOLD = 0.3;

// A crop bigger than this (px, longer side) is almost always oversampled for its module count,
// so shrink it before decode2d - cheaper, and no less reliable for a close-up code - capped at
// MAX_DECODE_DOWNSCALE so a genuinely complex/high-version code that happens to fill the frame
// isn't pushed below a safe pixels-per-module floor. Values as validated by the advanced demo's
// parameter sweep (see its parameter-sweep-findings.md).
const DECODE_TARGET_CROP_SIZE = 400;
const MAX_DECODE_DOWNSCALE = 3;

// A queued decode job older than this is dropped unsent rather than dispatched - by the time the
// (single) worker would reach it, the frame it came from is stale enough that decoding it is
// wasted work. MAX_DECODE_QUEUE bounds how many candidates can be waiting at once; there's only
// one worker, so this is a queue depth, not a pool size.
const MAX_QUEUE_AGE_MS = 1000;
const MAX_DECODE_QUEUE = 4;

function boundingBox(corners) {
    let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
    for (const [x, y] of corners) {
        if (x < x0) x0 = x;
        if (y < y0) y0 = y;
        if (x > x1) x1 = x;
        if (y > y1) y1 = y;
    }
    return {x0, y0, x1, y1};
}

// Intersection-over-union of two `{x0,y0,x1,y1}` boxes, 0 (no overlap) to 1 (identical).
function iou(a, b) {
    const x0 = Math.max(a.x0, b.x0);
    const y0 = Math.max(a.y0, b.y0);
    const x1 = Math.min(a.x1, b.x1);
    const y1 = Math.min(a.y1, b.y1);
    const interArea = Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
    if (interArea === 0) return 0;
    const areaA = (a.x1 - a.x0) * (a.y1 - a.y0);
    const areaB = (b.x1 - b.x0) * (b.y1 - b.y0);
    return interArea / (areaA + areaB - interArea);
}

// Crop a sub-frame out of `luma` (row-major, `width`x`height`), expanding by `pad` pixels and
// clamping to the source bounds, so a tight `locate()` box still leaves the decoder its quiet
// zone.
function cropGray(width, height, luma, box, pad) {
    const x0 = Math.max(0, Math.floor(box.x0 - pad));
    const y0 = Math.max(0, Math.floor(box.y0 - pad));
    const x1 = Math.min(width, Math.ceil(box.x1 + pad));
    const y1 = Math.min(height, Math.ceil(box.y1 + pad));
    const w = Math.max(1, x1 - x0);
    const h = Math.max(1, y1 - y0);
    const out = new Uint8Array(w * h);
    for (let row = 0; row < h; row++) {
        const srcStart = (y0 + row) * width + x0;
        out.set(luma.subarray(srcStart, srcStart + w), row * w);
    }
    return {width: w, height: h, luma: out, offsetX: x0, offsetY: y0};
}

// Pick how much to shrink this candidate's crop before decode2d(), from its own detected size -
// `1` (no change) for the common small/typical case; only a candidate detected as unusually
// large gets downscaled, and only by as much as its size actually calls for.
function chooseDecodeDownscale(box, targetSize, maxDownscale) {
    const size = Math.max(box.x1 - box.x0, box.y1 - box.y0);
    return Math.min(maxDownscale, Math.max(1, Math.floor(size / targetSize)));
}

// Crop `box` (expanded by `pad`, clamped to the frame) for decoding, downscaling by
// `decodeDownscale` in the same step when that's `> 1`. At `1` this is exactly `cropGray`; a
// real downscale instead re-samples from `sourceCanvas` (the RGBA source) so the browser's own
// image scaling does the antialiased averaging a manual subsample of an already-grayscale array
// would not.
function cropForDecode(width, height, luma, box, pad, decodeDownscale, sourceCanvas, scratchCanvas, scratchCtx) {
    if (decodeDownscale === 1) {
        return {...cropGray(width, height, luma, box, pad), decodeDownscale};
    }
    const x0 = Math.max(0, Math.floor(box.x0 - pad));
    const y0 = Math.max(0, Math.floor(box.y0 - pad));
    const x1 = Math.min(width, Math.ceil(box.x1 + pad));
    const y1 = Math.min(height, Math.ceil(box.y1 + pad));
    const srcW = Math.max(1, x1 - x0);
    const srcH = Math.max(1, y1 - y0);
    const dstW = Math.max(1, Math.round(srcW / decodeDownscale));
    const dstH = Math.max(1, Math.round(srcH / decodeDownscale));
    if (scratchCanvas.width !== dstW || scratchCanvas.height !== dstH) {
        scratchCanvas.width = dstW;
        scratchCanvas.height = dstH;
    }
    scratchCtx.drawImage(sourceCanvas, x0, y0, srcW, srcH, 0, 0, dstW, dstH);
    const cropLuma = toGrayFrame(scratchCtx.getImageData(0, 0, dstW, dstH)).luma;
    return {width: dstW, height: dstH, luma: cropLuma, offsetX: x0, offsetY: y0, decodeDownscale};
}

// Builds the off-main-thread decode2d()/decode1d() half of the pipeline: a single Worker (its
// source assembled from a template string, not a separate file, to keep this pipeline contained
// in this component) that dynamically imports the same anyd-qr.js bundle already used on the
// main thread for locate(), so a slow decode never blocks capture, detection, or this
// component's own overlay/UI rendering. It gets its own wasm instance - a worker's wasm memory
// isn't shared with the main thread - via that import's own `loadAnyDCode()` cache, exactly like
// the main thread's `anydPromise` below.
function createDecodeWorker() {
    const absoluteAnydUrl = new URL(anydUrl, window.location.href).href;
    const source = `
import { loadAnyDCode } from ${JSON.stringify(absoluteAnydUrl)};
const anydPromise = loadAnyDCode();
self.onmessage = async (event) => {
    const {jobId, width, height, luma} = event.data;
    try {
        const anyd = await anydPromise;
        const codes = anyd.decodeImage({width, height, luma: new Uint8Array(luma)});
        self.postMessage({type: "result", jobId, codes});
    } catch (err) {
        self.postMessage({type: "error", jobId, message: err?.message ?? String(err)});
    }
};
`;
    const blobUrl = URL.createObjectURL(new Blob([source], {type: "application/javascript"}));
    const worker = new Worker(blobUrl, {type: "module"});
    URL.revokeObjectURL(blobUrl);
    return worker;
}

export default {
    name: "CameraScanner",
    components: {
        ...BIcons
    },
    props: {
        // Whether to open the camera as soon as it's available (still gated on browser support
        // and a secure context - the camera API is unavailable otherwise). false lets a caller
        // that wants to gate opening the camera behind its own condition (e.g. a "scan" button)
        // skip the auto-open.
        autostart: {
            type: Boolean,
            default: true,
        },
    },
    emits: [
        // Fired once per onDecode batch with anyd-qr.js's raw decoded codes (type/text/metadata/box).
        "scan",
        // Fired with a message string when a camera error occurs, or null once it clears (e.g. on
        // reconnect) - lets the caller show/clear it alongside errors from its own other sources.
        "error",
    ],
    data() {
        return {
            cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
            cameras: [],
            localSelectedCameraId: "",
            cameraRunning: false,
            cameraRes: "",
        };
    },
    computed: {
        // Switches camera immediately on selection change (no explicit "apply" step), matching
        // prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
        selectedCameraId: {
            get() {
                return this.localSelectedCameraId;
            },
            set(value) {
                this.localSelectedCameraId = value;
                if (this.cameraRunning) {
                    this.switchCamera(value || null);
                }
            },
        },
    },
    methods: {
        // Device labels only populate once permission is granted, so this is called again right
        // after getUserMedia resolves in startCamera, not just once up front.
        async populateCameraList() {
            if (!navigator.mediaDevices?.enumerateDevices) {
                return;
            }
            await cameraManager.enumerateDevices();
            this.cameras = cameraManager.getAvailableCameras();
        },

        cleanCameraLabel(label) {
            if (!label) return "";
            const parts = label.split(":").map((p) => p.trim());
            if (parts.length === 2 && parts[0] === parts[1]) {
                return parts[0];
            }
            return label;
        },

        syncOverlaySize() {
            const video = this.$refs.video;
            const overlay = this.$refs.overlay;
            if (!video || !overlay) {
                return;
            }
            overlay.width = video.clientWidth;
            overlay.height = video.clientHeight;
        },

        // Draws each detected code's box (video's native pixel space, scaled to the overlay's
        // displayed size) and fades it out after OVERLAY_CLEAR_MS.
        drawOverlay(codes) {
            const overlay = this.$refs.overlay;
            const video = this.$refs.video;
            if (!overlay || !video || !video.videoWidth) {
                return;
            }
            const ctx = overlay.getContext("2d");
            ctx.clearRect(0, 0, overlay.width, overlay.height);
            const scaleX = overlay.width / video.videoWidth;
            const scaleY = overlay.height / video.videoHeight;
            ctx.lineWidth = 3;
            ctx.strokeStyle = "#3ce77c";
            ctx.fillStyle = "#3ce77c";
            ctx.font = "14px sans-serif";
            for (const code of codes) {
                if (!code.box) {
                    continue;
                }
                const {x0, y0, x1, y1} = code.box;
                ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
                ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
            }
            clearTimeout(this.clearOverlayTimer);
            this.clearOverlayTimer = setTimeout(
                () => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
        },

        // Attaches `stream` to the video element so the scanner never needs recreating. See
        // docs/implementation.md#video-stream-attach-and-resize-sync.
        setupVideoStream(stream) {
            const video = this.$refs.video;
            if (!video) return;
            video.srcObject = stream;
            video.play();
        },

        // Resizes the overlay to match the video's new size once its intrinsic dimensions change.
        // See docs/implementation.md#video-stream-attach-and-resize-sync.
        onVideoResize() {
            const video = this.$refs.video;
            if (!video || !video.videoWidth) {
                return;
            }
            this.syncOverlaySize();
            this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
        },

        async startCamera() {
            this.$emit("error", null);
            try {
                const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
                this.setupVideoStream(stream);
                await this.populateCameraList();
                this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";

                const anyd = await this.anydPromise;
                this.detectWasm = anyd.raw;
                this.seen = new Map();
                this.cooldownEntries = [];
                clearInterval(this.captureTimer);
                this.captureTimer = setInterval(() => this.captureTick(), 1000 / CAPTURE_FPS);
                this.cameraRunning = true;
            } catch (e) {
                this.$emit("error", `Camera error: ${e.message ?? e}`);
            }
        },

        // The tick driving the whole pipeline: grab+downscale the current video frame, run
        // `locate()` right here on the main thread (cheap enough not to need a worker of its
        // own), then hand each candidate crop off to the decode worker rather than decoding it
        // inline - see createDecodeWorker()'s doc comment for why.
        captureTick() {
            const video = this.$refs.video;
            if (!video || !video.videoWidth || !video.videoHeight || !this.detectWasm) {
                return;
            }

            const width = Math.max(1, Math.round(video.videoWidth / CAPTURE_DOWNSCALE));
            const height = Math.max(1, Math.round(video.videoHeight / CAPTURE_DOWNSCALE));
            if (this.captureCanvas.width !== width || this.captureCanvas.height !== height) {
                this.captureCanvas.width = width;
                this.captureCanvas.height = height;
            }
            this.captureCtx.drawImage(video, 0, 0, width, height);
            const frame = toGrayFrame(this.captureCtx.getImageData(0, 0, width, height));

            let candidates;
            try {
                candidates = this.detectWasm.locate(width, height, frame.luma);
            } catch (err) {
                this.$emit("error", `Camera error: ${err.message ?? err}`);
                return;
            }
            if (!Array.isArray(candidates)) {
                return;
            }

            const now = performance.now();
            for (const candidate of candidates) {
                if (!candidate?.corners) continue;
                const box = boundingBox(candidate.corners);
                const nativeBox = {
                    x0: box.x0 * CAPTURE_DOWNSCALE,
                    y0: box.y0 * CAPTURE_DOWNSCALE,
                    x1: box.x1 * CAPTURE_DOWNSCALE,
                    y1: box.y1 * CAPTURE_DOWNSCALE,
                };

                // Something decoded near here recently, and the worker has other work pending -
                // skip re-decoding this candidate rather than spend it re-reading a code that's
                // (almost certainly) still just sitting there. If the worker/queue is otherwise
                // idle, decode anyway: there's no contention to save, and it keeps this
                // location's cooldown entry from going stale between real hits.
                if ((this.decodeWorkerBusy || this.decodeQueue.length > 0) && this.findCooldownNear(nativeBox, now)) {
                    continue;
                }

                const decodeDownscale = chooseDecodeDownscale(box, DECODE_TARGET_CROP_SIZE, MAX_DECODE_DOWNSCALE);
                const crop = cropForDecode(
                    width, height, frame.luma, box, 4, decodeDownscale,
                    this.captureCanvas, this.decodeScratchCanvas, this.decodeScratchCtx);
                this.enqueueDecodeJob(crop, decodeDownscale, now);
            }
        },

        enqueueDecodeJob(crop, decodeDownscale, now) {
            this.pruneStaleJobs(now);
            if (this.decodeQueue.length >= MAX_DECODE_QUEUE) {
                return;
            }
            this.decodeQueue.push({
                jobId: ++this.jobCounter,
                generation: this.captureGeneration,
                enqueuedAt: now,
                payload: {jobId: this.jobCounter, width: crop.width, height: crop.height, luma: crop.luma.buffer},
                transfer: [crop.luma.buffer],
                offsetX: crop.offsetX,
                offsetY: crop.offsetY,
                downscale: CAPTURE_DOWNSCALE,
                decodeDownscale,
            });
            this.dispatchNextJob();
        },

        // FIFO order means the front of the queue is always the oldest, so the first
        // non-expired entry means nothing behind it can be expired either - one pass from the
        // front is sufficient.
        pruneStaleJobs(now) {
            while (this.decodeQueue.length > 0 && now - this.decodeQueue[0].enqueuedAt > MAX_QUEUE_AGE_MS) {
                this.decodeQueue.shift();
            }
        },

        dispatchNextJob() {
            if (this.decodeWorkerBusy) {
                return;
            }
            this.pruneStaleJobs(performance.now());
            const job = this.decodeQueue.shift();
            if (!job) {
                return;
            }
            this.decodeWorkerBusy = true;
            this.pendingJob = job;
            this.decodeWorker.postMessage(job.payload, job.transfer);
        },

        // The worker only ever saw this one candidate's crop, so any `box` it reports is
        // relative to that crop's own pixels - coarser than the capture frame's if this
        // candidate got its own decode-time downscale. Scale back up by that factor to land in
        // the crop's own space, then apply its offset, then the capture downscale in effect
        // when this job was dispatched, to land on native video pixels.
        handleWorkerMessage(event) {
            const msg = event.data;
            const job = this.pendingJob;
            this.decodeWorkerBusy = false;
            this.pendingJob = null;

            if (msg.type === "error") {
                console.error("[camera scan]", msg.message);
            } else if (job && job.jobId === msg.jobId && job.generation === this.captureGeneration) {
                const now = performance.now();
                const codes = (msg.codes ?? []).map((code) => ({
                    ...code,
                    box: code.box && {
                        x0: (code.box.x0 * job.decodeDownscale + job.offsetX) * job.downscale,
                        y0: (code.box.y0 * job.decodeDownscale + job.offsetY) * job.downscale,
                        x1: (code.box.x1 * job.decodeDownscale + job.offsetX) * job.downscale,
                        y1: (code.box.y1 * job.decodeDownscale + job.offsetY) * job.downscale,
                    },
                }));

                for (const code of codes) {
                    if (code.box) this.rememberCooldown(code.box, now);
                }

                const dedupeNow = Date.now();
                const fresh = codes.filter((code) => {
                    const key = `${code.type}|${code.text ?? ""}`;
                    const lastSeen = this.seen.get(key);
                    if (lastSeen !== undefined && dedupeNow - lastSeen < EMIT_DEDUPE_MS) {
                        return false;
                    }
                    this.seen.set(key, dedupeNow);
                    return true;
                });
                if (fresh.length > 0) {
                    this.$emit("scan", fresh);
                    this.drawOverlay(fresh);
                }
            }

            this.dispatchNextJob();
        },

        rememberCooldown(box, now) {
            const existing = this.cooldownEntries.find((e) => iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
            if (existing) {
                existing.box = box;
                existing.decodedAt = now;
            } else {
                this.cooldownEntries.push({box, decodedAt: now});
            }
            // Prune well past the cooldown window so a long session doesn't accumulate forever.
            this.cooldownEntries = this.cooldownEntries.filter((e) => now - e.decodedAt < COOLDOWN_MS * 4);
        },

        findCooldownNear(box, now) {
            return this.cooldownEntries.some(
                (e) => now - e.decodedAt < COOLDOWN_MS && iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
        },

        async switchCamera(cameraId) {
            if (!this.cameraRunning) return;
            try {
                const stream = await cameraManager.switchCamera(cameraId);
                if (stream) this.setupVideoStream(stream);
            } catch (e) {
                this.$emit("error", `Camera error: ${e.message ?? e}`);
            }
        },

        stopCamera() {
            clearInterval(this.captureTimer);
            this.captureTimer = null;
            this.decodeQueue = [];
            // Bumped so a decode job dispatched before this stop - still in flight in the
            // worker - is recognized as stale and discarded once its result arrives (see
            // handleWorkerMessage), rather than applied against whatever's running by then.
            // decodeWorkerBusy/pendingJob are deliberately left alone: they resolve themselves
            // when that in-flight result lands.
            this.captureGeneration++;
            cameraManager.closeStream();
            if (this.$refs.video) {
                this.$refs.video.srcObject = null;
            }
            this.cameraRunning = false;
            const overlay = this.$refs.overlay;
            if (overlay) {
                overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
            }
        },

        // cameraManager already tries a fallback device and only fires this event once none
        // remain, so the running scan session has to stop here.
        handleCameraDisconnected(event) {
            if (!this.cameraRunning) return;
            this.$emit("error", `Camera error: ${event.detail?.error || "Camera disconnected"}`);
            this.stopCamera();
        },

        handleCameraReconnected() {
            if (!this.cameraRunning) return;
            const stream = cameraManager.getActiveStream();
            if (!stream) return;
            this.$emit("error", null);
            this.setupVideoStream(stream);
            this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
        },
    },
    created() {
        // Kept as a promise (not awaited) so this and Scan.vue's own file-decode path share one
        // in-flight load; loadAnyDCode also memoizes, so later calls elsewhere in the app are
        // free too.
        this.anydPromise = loadAnyDCode();
        this.detectWasm = null;
        this.clearOverlayTimer = null;
        this.captureTimer = null;
        this.captureGeneration = 0;
        this.seen = new Map();
        this.cooldownEntries = [];

        // decode2d()/decode1d() run in this worker, off the main thread, so a slow decode never
        // stalls locate()/capture or this component's own rendering - see createDecodeWorker().
        // Long-lived for the component's lifetime (not recreated per start/stop) so switching
        // cameras or restarting the scan doesn't pay wasm-instantiation cost again.
        this.decodeWorker = createDecodeWorker();
        this.decodeWorker.onmessage = (event) => this.handleWorkerMessage(event);
        this.decodeWorkerBusy = false;
        this.pendingJob = null;
        this.jobCounter = 0;
        this.decodeQueue = [];

        this.captureCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
        this.captureCtx = this.captureCanvas.getContext("2d");
        this.decodeScratchCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
        this.decodeScratchCtx = this.decodeScratchCanvas.getContext("2d");
    },
    mounted() {
        window.addEventListener("resize", this.syncOverlaySize);
        window.addEventListener("camera-disconnected", this.handleCameraDisconnected);
        window.addEventListener("camera-reconnected", this.handleCameraReconnected);
        navigator.mediaDevices?.addEventListener("devicechange", this.populateCameraList);
        this.$refs.video?.addEventListener("loadedmetadata", this.onVideoResize);
        this.$refs.video?.addEventListener("resize", this.onVideoResize);
        if (this.cameraSupported && window.isSecureContext && this.autostart) {
            this.startCamera();
        } else {
            this.populateCameraList();
        }
    },
    beforeUnmount() {
        this.stopCamera();
        window.removeEventListener("resize", this.syncOverlaySize);
        window.removeEventListener("camera-disconnected", this.handleCameraDisconnected);
        window.removeEventListener("camera-reconnected", this.handleCameraReconnected);
        navigator.mediaDevices?.removeEventListener("devicechange", this.populateCameraList);
        this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
        this.$refs.video?.removeEventListener("resize", this.onVideoResize);
        clearTimeout(this.clearOverlayTimer);
        this.decodeWorker.terminate();
    },
}
</script>

<style scoped>
.video-wrap {
    position: relative;
    margin-bottom: .75rem;
    background: #000;
    border-radius: .35rem;
    overflow: hidden;
}

.video-wrap video {
    display: block;
    width: 100%;
    height: auto;
}

.video-wrap canvas {
    position: absolute;
    inset: 0;
    pointer-events: none;
}
</style>
