All files / src/web rewind-thumbnail.js

96.82% Statements 61/63
85.71% Branches 12/14
83.33% Functions 5/6
100% Lines 53/53

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123    3x 3x 3x 3x 3x 3x   3x       3x 3x 3x     78x 2x 2x 2x 2x 2x                     78x   78x 78x   78x 78x 78x 78x 78x 78x                         126x 126x     126x 823x 822x       125x 125x 125x 125x 125x 819x 819x     125x                                           33x   32x 32x 32x   32x 32x 79x 79x 79x 79x 79x 79x 79x     32x     31x        
import { NoReplayHooks } from "../6502.js";
 
const ThumbnailWidth = 160;
const ThumbnailHeight = 128;
const FramebufferWidth = 1024;
const FramebufferHeight = 625;
const VisiblePixels = FramebufferWidth * FramebufferHeight;
const CyclesPerChunk = 8000;
// Safety limit to prevent infinite loops if video state is broken
const MaxChunks = 100;
 
// Reusable offscreen canvas and ImageData for captureThumbnail to avoid
// allocating a full 1024x625 buffer per thumbnail.
let srcCanvas = null;
let srcCtx = null;
let srcImageData = null;
 
function ensureSrcCanvas() {
    if (srcCanvas) return;
    srcCanvas = document.createElement("canvas");
    srcCanvas.width = FramebufferWidth;
    srcCanvas.height = FramebufferHeight;
    srcCtx = srcCanvas.getContext("2d", { alpha: false });
    srcImageData = srcCtx.createImageData(FramebufferWidth, FramebufferHeight);
}
 
/**
 * Render a single thumbnail canvas from a framebuffer Uint32Array.
 * Reuses a shared offscreen canvas for the full-size copy, then
 * downscales into a new small canvas for the thumbnail.
 * @param {Uint32Array} fb32 - the framebuffer to capture
 * @returns {HTMLCanvasElement} a small canvas with the downscaled image
 */
function captureThumbnail(fb32) {
    ensureSrcCanvas();
    // fb32 may be 1024x1024 (WebGL) or 1024x625 (2D) — only copy the visible region
    new Uint32Array(srcImageData.data.buffer).set(fb32.subarray(0, VisiblePixels));
    srcCtx.putImageData(srcImageData, 0, 0);
 
    const thumb = document.createElement("canvas");
    thumb.width = ThumbnailWidth;
    thumb.height = ThumbnailHeight;
    const thumbCtx = thumb.getContext("2d", { alpha: false });
    thumbCtx.drawImage(srcCanvas, 0, 0, FramebufferWidth, FramebufferHeight, 0, 0, ThumbnailWidth, ThumbnailHeight);
    return thumb;
}
 
/**
 * Execute cycles until a complete top-to-bottom frame is in fb32, as a replay, so that no breakpoint or watch cuts
 * the preview short and nothing it prints or speaks reaches the host.
 *
 * Snapshots may be mid-frame, so we run through two vsyncs:
 * 1. First vsync completes the partial frame and clears fb32 normally.
 * 2. Second vsync rasterises a full frame; we suppress clearPaintBuffer
 *    so fb32 retains the completed frame for capture.
 */
function executeUntilFrame(processor, video) {
    processor.asReplay(NoReplayHooks, () => {
        const startFrame = video.frameCount;
 
        // Phase 1: run to first vsync (completes partial frame, clears fb32)
        for (let i = 0; i < MaxChunks; i++) {
            Iif (processor.execute(CyclesPerChunk) === false) break;
            if (video.frameCount !== startFrame) break;
        }
 
        // Phase 2: run to second vsync with clear suppressed (full frame in fb32)
        const secondFrame = video.frameCount;
        const origClear = video.clearPaintBuffer;
        video.clearPaintBuffer = function () {};
        try {
            for (let i = 0; i < MaxChunks; i++) {
                Iif (processor.execute(CyclesPerChunk) === false) return;
                if (video.frameCount !== secondFrame) return;
            }
        } finally {
            video.clearPaintBuffer = origClear;
        }
    });
}
 
/**
 * Generate thumbnail canvases for all snapshots in a rewind buffer.
 *
 * While the emulator is paused:
 * 1. Saves the current state
 * 2. For each snapshot (oldest first), restores it, runs until a full
 *    frame has been rasterised, and captures a downscaled thumbnail
 * 3. Restores the original state
 *
 * @param {object} processor - the Cpu6502 instance
 * @param {object[]} snapshots - array of snapshots (oldest first)
 * @param {object} video - the Video instance (used to access fb32)
 * @param {number} captureInterval - rewind capture interval in frames (~50)
 * @param {object} [savedState] - pre-saved state to restore after rendering (avoids double snapshot)
 * @returns {{canvas: HTMLCanvasElement, index: number, ageSeconds: number}[]}
 */
export function renderThumbnails(processor, snapshots, video, captureInterval, savedState) {
    if (snapshots.length === 0) return [];
 
    if (!savedState) savedState = processor.snapshotState();
    const framesPerSecond = 50;
    const results = [];
 
    try {
        for (let i = 0; i < snapshots.length; i++) {
            processor.restoreState(snapshots[i]);
            executeUntilFrame(processor, video);
            video.renderPending();
            const canvas = captureThumbnail(video.fb32);
            const stepsFromNewest = snapshots.length - 1 - i;
            const ageSeconds = Math.round((stepsFromNewest * captureInterval) / framesPerSecond);
            results.push({ canvas, index: i, ageSeconds });
        }
    } finally {
        processor.restoreState(savedState);
    }
 
    return results;
}
 
export { executeUntilFrame };