Source: overlays/screenshot/screenshot-render.mjs

import { OpenSeadragon } from '../../osd-loader.mjs';
import { changeDpiBlob } from './changedpi.mjs';

const BASE_SCREEN_DPI = 96;

/**
 * Waits for `n` requestAnimationFrame ticks (falls back to setTimeout).
 * Useful to ensure painted state after OSD viewport operations.
 * @param {number} n - Number of frames to wait
 * @returns {Promise<void>}
 */
export function waitForNextPaintFrames(n = 2){
    n = Math.max(0, Math.floor(Number(n) || 0));
    if(n === 0) return Promise.resolve();
    const raf = (typeof window !== 'undefined' && window.requestAnimationFrame)
        ? window.requestAnimationFrame.bind(window)
        : null;
    if(!raf){
        return new Promise(resolve => setTimeout(resolve, 50));
    }
    return new Promise(resolve => {
        const tick = () => {
            n -= 1;
            if(n <= 0) resolve();
            else raf(tick);
        };
        raf(tick);
    });
}

/**
 * Builds a JSON signature string that uniquely identifies a render request,
 * used for cache invalidation.
 * @param {object} params
 * @param {number} params.w - Output width in device pixels
 * @param {number} params.h - Output height in device pixels
 * @param {OpenSeadragon.Rect} params.viewportRect - Viewport-space rectangle
 * @param {number} params.rotation - Viewport rotation
 * @param {object} params.tileSource - The OSD tile source object
 * @returns {string}
 */
export function computeRenderSignature({ w, h, viewportRect, rotation, tileSource }){
    const vr = viewportRect;
    const ts = tileSource;
    return JSON.stringify({
        viewportRect: { x: vr.x, y: vr.y, width: vr.width, height: vr.height, degrees: vr.degrees },
        outW: w,
        outH: h,
        rotation,
        tileSourceKey: ts?.url || ts?.tilesUrl || ts?.tileUrl || ts?.Image?.Url || null,
    });
}

/**
 * Creates an off-screen OpenSeadragon viewer, renders the specified viewport
 * region at the given pixel dimensions, and returns the result as a Blob.
 *
 * @param {object} params
 * @param {number} params.w - Output width in device pixels
 * @param {number} params.h - Output height in device pixels
 * @param {OpenSeadragon.Rect} params.viewportRect - Viewport-space rectangle to render
 * @param {object} params.viewer - The main OSD viewer (for config like crossOriginPolicy)
 * @param {object} params.tiledImage - The active tiled image
 * @param {function} [params.onProgress] - Optional progress callback(loaded, total)
 * @returns {Promise<{blob: Blob, pixelRatio: number, signature: string}>}
 */
export async function renderBaseScreenshot({ w, h, viewportRect, viewer, tiledImage, onProgress }){
    const pixelRatio = OpenSeadragon.pixelDensityRatio;
    const cssW = w / pixelRatio;
    const cssH = h / pixelRatio;

    const ti = tiledImage;
    const ts = ti.source || viewer.tileSources[viewer.currentPage()];
    const rotation = viewer.viewport.getRotation(true);

    const signature = computeRenderSignature({ w, h, viewportRect, rotation, tileSource: ts });

    const d = document.createElement('div');
    document.body.appendChild(d);
    d.style.cssText = `width:${cssW}px;height:${cssH}px;position:fixed;left:-${cssW*2}px;`;

    let ssViewer = null;
    try{
        ssViewer = OpenSeadragon({
            element: d,
            tileSources: [ts],
            crossOriginPolicy: viewer.crossOriginPolicy,
            prefixUrl: viewer.prefixUrl,
            immediateRender: true,
        });
        ssViewer.viewport.setRotation(rotation, true);

        if(onProgress){
            ssViewer.addHandler('tile-drawn', (ev) => {
                const coverage = ev.tiledImage.coverage;
                const levels = Object.keys(coverage);
                const maxLevel = levels[levels.length - 1];
                if(ev.tile.level == maxLevel){
                    const full = coverage[maxLevel];
                    const status = Object.values(full).map(o => Object.values(o)).flat();
                    onProgress(status.filter(x => x).length, status.length);
                }
            });
        }

        await new Promise((resolve, reject) => {
            ssViewer.addHandler('open', () => {
                try{
                    ssViewer.world.getItemAt(0).setRotation(ti.getRotation(true), true);
                    ssViewer.viewport.fitBounds(viewportRect, true);
                    ssViewer.world.getItemAt(0).addOnceHandler('fully-loaded-change', () => {
                        resolve();
                    });
                }catch(e){
                    reject(e);
                }
            });
            ssViewer.addHandler('open-failed', reject);
        });

        await waitForNextPaintFrames(2);

        let blob = await new Promise((resolve) => ssViewer.drawer.canvas.toBlob(resolve));
        if(!blob) throw new Error('Failed to export screenshot canvas.');
        if(pixelRatio !== 1){
            blob = await changeDpiBlob(blob, BASE_SCREEN_DPI * pixelRatio);
        }

        return { blob, pixelRatio, signature };
    } finally {
        try{
            const container = ssViewer?.element;
            ssViewer?.destroy?.();
            container?.remove?.();
        }catch{ /* ignore */ }
        try{ d.remove(); }catch{ /* ignore */ }
    }
}

/**
 * Composes a scalebar onto a base screenshot blob.
 *
 * @param {object} params
 * @param {Blob} params.baseBlob - The base screenshot blob
 * @param {number} params.pixelRatio - Device pixel ratio used during base render
 * @param {number} params.scaleFactor - Output scale factor (output pixels / base pixels)
 * @param {object} params.scalebar - Scalebar configuration
 * @param {boolean} params.scalebar.include - Whether to draw a scalebar
 * @param {number} params.scalebar.widthMm - Scalebar physical length in mm
 * @param {number} params.scalebar.heightPx - Scalebar bar height in pixels
 * @param {number} params.scalebar.mppX - Microns per pixel along X axis
 * @param {object} [params.scalebar.label] - Label fit info from _scalebarLabelFit
 * @returns {Promise<Blob>}
 */
export async function composeScreenshotWithScalebar({ baseBlob, pixelRatio, scaleFactor, scalebar }){
    const img = (typeof createImageBitmap === 'function')
        ? await createImageBitmap(baseBlob)
        : await new Promise((resolve, reject) => {
            const url = URL.createObjectURL(baseBlob);
            const image = new Image();
            image.onload = () => { URL.revokeObjectURL(url); resolve(image); };
            image.onerror = (e) => { URL.revokeObjectURL(url); reject(e); };
            image.src = url;
        });

    const canvas = document.createElement('canvas');
    canvas.width = img.width;
    canvas.height = img.height;
    const ctx = canvas.getContext('2d');
    ctx.drawImage(img, 0, 0);

    if(scalebar.include && scalebar.mppX){
        const PADDING = 12;
        const pxLen = Math.max(1, Math.round(scalebar.widthMm * 1000 / scalebar.mppX * scaleFactor));
        const pxH = Math.max(1, Math.round(scalebar.heightPx));
        const x2 = canvas.width - PADDING;
        const y2 = canvas.height - PADDING;
        const x1 = Math.max(PADDING, x2 - pxLen);
        const y1 = Math.max(PADDING, y2 - pxH);
        ctx.fillStyle = '#000';
        ctx.fillRect(x1, y1, x2 - x1, y2 - y1);

        if(scalebar.label?.enabled && scalebar.label?.fits && scalebar.label?.label){
            ctx.save();
            ctx.font = `${scalebar.label.fontPx || 12}px system-ui, -apple-system, Segoe UI, Roboto, sans-serif`;
            ctx.fillStyle = '#fff';
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            ctx.fillText(scalebar.label.label, (x1 + x2) / 2, (y1 + y2) / 2);
            ctx.restore();
        }
    }

    let blob = await new Promise((resolve) => canvas.toBlob(resolve));
    if(!blob) throw new Error('Failed to export composed screenshot.');
    if(pixelRatio !== 1){
        blob = await changeDpiBlob(blob, BASE_SCREEN_DPI * pixelRatio);
    }
    return blob;
}