Commit ff6e66ac authored by Laureline Nevin's avatar Laureline Nevin ⌨️
Browse files

Implemented export worker taking zoom into account

parent 7cc9302f
Loading
Loading
Loading
Loading
+30 −21
Changes for index.html: 30 added lines, 21 removed lines.
Original line number Diff line number Diff line
@@ -10,8 +10,8 @@
        let FF_FOUC_FIX;
    </script>
</head>
<body v-scope>
<div class="body-content" :style="{cursor: fn.getCursor()}">
<body v-scope v-effect="document.title = title">
<div class="body-content" :style="{cursor: fn.getCursorType()}">
    <h1 class="title" title="Image Viewer with Online Interactive Relighting" v-text="title"></h1>

    <header class="navbar" v-cloak>
@@ -19,23 +19,23 @@
            <label for="select-dataset" v-t="controls.selected"></label>
            <span v-t="colon"></span>
            <select name="dataset" id="select-dataset" class="btn-dark" :disabled="datasetsDisabled"
                    :value="dataset" @change="app.onDatasetSelect($event)">
                    :value="dataset" @change="fn.onDatasetSelect($event)">
                <optgroup label="" :label="t('controls.hosted')" class="builtins">
                    <option v-for="ds in datasets.server" :value="ds.id" v-text="loc(ds.name, locale)"></option>
                    <option v-for="ds in datasets.server" :value="ds.id" v-t.dyn="ds.name"></option>
                </optgroup>
                <optgroup label="" :label="t('controls.inmemory')" class="inmemory">
                    <option v-for="ds in datasets.memory" :value="ds.id" v-text="loc(ds.name, locale)"></option>
                    <option v-for="ds in datasets.memory" :value="ds.id" v-t.dyn="ds.name"></option>
                    <option class="action" value="create" v-t="controls.createinmemory"></option>
                </optgroup>
            </select>
            <button id="button-delete-dataset" class="btn-dark" v-show="dataset.startsWith('memory:')"
                    @click="app.deleteMemoryDataset(dataset)" v-t="controls.deleteinmemory"></button>
                    @click="fn.deleteMemoryDataset(dataset)" v-t="controls.deleteinmemory"></button>
        </div>
        <div>
            <button class="btn-dark btn-icon" aria-label="Fullscreen" title="Fullscreen" v-icon="fullscreen"
                    v-show="requestFullscreen" @click="requestFullscreen"></button>
            <select aria-label="Language" class="btn-dark" name="lang"
                    :value="locale" @change="app.setLocale($event.target.value)">
                    :value="locale" @change="fn.setLocale($event.target.value)">
                <option v-for="lang in locales" :value="lang.id">{{lang.name}}</option>
            </select>
            <button class="btn-dark" @click="$refs.docDialog.showModal()">
@@ -133,23 +133,32 @@
                          @click="fn.setExportParams('view')"></button> <span v-t="export.settoview"></span></span>
            <span><button class="btn-dark btn-icon" v-icon="zoom-fit"
                          @click="fn.setExportParams('full')"></button> <span v-t="export.settofull"></span></span>
            <span>Zoom:
                <input aria-label="Zoom numerator" type="number" name="export-zoom-num"
                       min="1" max="1024" step="1"
                       :value="exportParams.zoom[0]" @change="fn.setExportZoom(0, $el.value)"> :
                <input aria-label="Zoom denominator" type="number" name="export-zoom-denom"
                       min="1" max="1024" step="1"
                       :value="exportParams.zoom[1]" @change="fn.setExportZoom(1, $el.value)">
            </span>
            <span>Export image size: {{fn.getExportSize().join(' : ')}}</span>
        </div>
        <span v-show="phase === 'render' || phase === 'export'">
            <button id="button-export" class="btn-dark btn-icon" @click="fn.exportImage" v-icon="export"></button>
            <button id="button-export" class="btn-dark btn-icon" @click="fn.exportImage()" v-icon="export"></button>
            <label for="button-export" v-t="export.button"></label>
        </span>
        <hr>
        <div class="description-container">
            <div v-if="description">
                <h3 v-t="controls.description"></h3>
                <div class="content" v-text="description && loc(description, locale)"></div>
                <div class="content" v-t.dyn="description"></div>
            </div>
            <pre v-if="output"><code v-text="output"></code></pre>
        </div>
        <div class="lightball-container">
            <canvas id="ivoire-lightball" :class="{lightballEdit}"></canvas>
            <div class="lightball-buttons-container" v-if="phase==='render' || phase==='export'">
                <button class="btn-dark btn-icon" @click="app.resetLightball()"
                <button class="btn-dark btn-icon" @click="fn.resetLightball()"
                        :title="t('controls.resetlightball')" v-icon="reset"></button>
                <button class="btn-dark btn-icon" v-icon="edit" aria-label="Edit angle values"
                        title="Edit angle values" @click="lightballEdit = !lightballEdit"
@@ -175,24 +184,24 @@

    <main id="app" v-cloak>
        <div class="view-controls">
            <button class="btn-dark btn-icon" :title="t('controls.fitview')" @click="fn.zoomFit()"
                    v-icon="zoom-fit"></button>
            <button class="btn-dark btn-icon" :title="t('controls.fitviewv')" @click="fn.zoomFit('v')"
                    v-icon="zoom-vfit"></button>
            <button class="btn-dark btn-icon" :title="t('controls.fitviewh')" @click="fn.zoomFit('h')"
                    v-icon="zoom-hfit"></button>
            <button class="btn-dark btn-icon" :title="t('controls.fitview')"
                    @click="fn.state.viewport.zoomFit()" v-icon="zoom-fit"></button>
            <button class="btn-dark btn-icon" :title="t('controls.fitviewv')"
                    @click="fn.state.viewport.zoomFit('v')" v-icon="zoom-vfit"></button>
            <button class="btn-dark btn-icon" :title="t('controls.fitviewh')"
                    @click="fn.state.viewport.zoomFit('h')" v-icon="zoom-hfit"></button>
        </div>
        <canvas id="ivoire-display" :style="{cursor: fn.getCursor()}"></canvas>
        <canvas id="ivoire-display" :style="{cursor: fn.getCursorType()}"></canvas>
        <progress aria-label="Downloading data" id="load-progress" class="hidden" max="100"></progress>
    </main>
</div>

<dialog id="message" class="drop-shadow" ref="messageDialog" @click="app.onDialogClick">
<dialog id="message" class="drop-shadow" ref="messageDialog" @click="fn.onDialogClick">
    <h1></h1>
    <p></p>
    <button class="btn-dark" type="reset" autofocus @click="$refs.messageDialog.close()" v-t="ok">Ok</button>
</dialog>
<dialog id="create-inmemory" ref="memCreateDia" class="drop-shadow" @click="app.onDialogClick"
<dialog id="create-inmemory" ref="memCreateDia" class="drop-shadow" @click="fn.onDialogClick"
        @close="fn.cancelCreateMemoryDataset($el.returnValue)">
    <h1 v-t="memcreate.title"></h1>
    <form action="#">
@@ -214,7 +223,7 @@
    <button class="btn" type="submit" v-t="memcreate.create"
            @click="fn.submitCreateMemoryDataset($event, $refs.memCreateDia)"></button>
</dialog>
<dialog id="export-success" ref="exportDialog" @click="app.onDialogClick"
<dialog id="export-success" ref="exportDialog" @click="fn.onDialogClick"
        @close="fn.onExportDialogClose($refs.exportImg)">
    <h3 v-t="export.success"></h3>
    <img src="" alt="Exported image" ref="exportImg">
@@ -225,7 +234,7 @@
    </div>
    <a href="" id="export-download-anchor" class="hidden"></a>
</dialog>
<dialog id="documentation" @click="app.onDialogClick" ref="docDialog">
<dialog id="documentation" @click="fn.onDialogClick" ref="docDialog">
    <button type="reset" class="btn-close btn-icon" v-icon="close" @click="$refs.docDialog.close()"></button>
    <div class="logos">
        <a target="_blank" href="https://www.greyc.fr">
+53 −52
Changes for src/app.ts: 53 added lines, 52 removed lines.
Original line number Diff line number Diff line
@@ -6,20 +6,19 @@ import {
    resizeCanvasToDisplaySize,
    SHADER_UPDATE_EVENT,
    shaderUpdateNotify,
    WebGLCanvasRenderingContext
    WebGLCanvasRenderingContext,
} from './gl-program.ts';
import { ControlUpdateEvent, getStateDefaults, LIGHT_ANGLE_DEFAULTS, State } from './control.ts';
import {
    arraysEq,
    catchError,
    clamp,
    DEBUG,
    exportValues,
    getLightSourceVec,
    matToString,
    resetMessage,
    roundTo,
    VERBOSE
    VERBOSE,
} from './utils.ts';
import { DownloadManager } from './download.ts';
import { Dataset, MemoryDataset, SingleDataset, TiledDataset } from './dataset.ts';
@@ -51,6 +50,7 @@ export class App {
    public readonly state: State;
    public readonly i18n: I18n;
    public readonly rx: typeof this.DEFAULT_RX_STATE;
    public readonly rawRx: typeof this.DEFAULT_RX_STATE;
    private renderRequested = false;
    private maxCanvasSize: [number, number] | undefined = undefined;
    public readonly gls: {
@@ -75,10 +75,6 @@ export class App {
        return this._app;
    }

    public static get appInitialized(): boolean {
        return this._app !== undefined;
    }

    public constructor(translations: Record<string, Record<string, any>>) {
        if (App._app) throw new Error(); // There can be only one

@@ -110,7 +106,8 @@ export class App {
            import.meta.hot.on('vite:afterUpdate', () => this.requestRender());
        }

        this.rx = reactive(this.DEFAULT_RX_STATE);
        this.rawRx = this.DEFAULT_RX_STATE;
        this.rx = reactive(this.rawRx);
        this.initControls();

        this.rx.phase = 'load-datasets';
@@ -181,15 +178,11 @@ export class App {
        if (locale) {
            console.debug('Setting locale to', locale);
        }
        const i18n = new I18n(translations, {

        return new I18n(translations, {
            locale,
            defaultLocale: App.DEFLANG,
        });
        i18n.onChange(() => {
            this.rx.locale = this.i18n.locale;
            this.rx.t = (k: string) => this.i18n.t(k); // Trigger update
        });
        return i18n;
    }

    private get DEFAULT_RX_STATE() {
@@ -197,7 +190,6 @@ export class App {
        const fn = Object.fromEntries(
            (
                [
                    this.getLocString,
                    this.setLocale,
                    this.deleteMemoryDataset,
                    this.onDatasetSelect,
@@ -211,15 +203,15 @@ export class App {
                    this.onExportDialogClose,
                    this.downloadExportImage,
                    this.setExportParams,
                    this.setExportZoom,
                    this.getExportSize,
                    this.onExportParamInput,
                    this.requestRender,
                    this.getCursor,
                    this.getCursorType,
                ] as Array<(..._: any[]) => any>
            )
                .map((m) => [m.name, m.bind(this)] as const)
                .concat([['zoomFit', this.state.viewport.zoomFit.bind(this.state.viewport)]]),
            ).map((m) => [m.name, m.bind(this)] as const),
        );
        const rx = {
        return {
            fn,
            phase: 'initial' as RunPhase,
            title: 'IVOIRe' + (import.meta.env.VITE_TITLE_TAG && ` (${import.meta.env.VITE_TITLE_TAG})`),
@@ -232,7 +224,10 @@ export class App {
            datasetsDisabled: false,
            dataset: '',
            requestFullscreen: null as (() => void) | null,
            t: (k: string) => this.i18n.t(k),
            t: (k: string): string => {
                void this.rx.locale;
                return this.i18n.t(k);
            },
            loc: this.getLocString.bind(this),
            locale: this.i18n.locale,
            locales: App.LANGS.map((id) => ({ id, name: this.i18n.translations[id].lang as string })),
@@ -240,24 +235,24 @@ export class App {
            output: null as string | null,
            exportParams: App.DEFAULT_EXPORT_PARAMS,
            totalSize: [0, 0] as [number, number],
            pointerState: this.state.pointerState,
            lightballEdit: false,
            raw: () => rx, // Function to un-proxify the rx object
        };
        return rx as Omit<typeof rx, 'raw'> & { raw: () => typeof rx };
    }

    static get DEFAULT_EXPORT_PARAMS() {
        return {
            area: new Rect(0, 0, 0, 0),
            dezoom: 0,
            zoom: [1, 1] as [number, number],
            zoomtype: 'int' as 'float' | 'int' | 'pow2',
            outerGrab: new Rect(0, 0, 0, 0),
            innerGrab: new Rect(0, 0, 0, 0),
            lockedDim: 'end',
        };
    }

    private getLocString(value: LocString, locale: string) {
    getLocString(value: LocString | null) {
        if (!value) return null;
        const locale = this.rx.locale;
        let text = typeof value === 'string' ? value : value[locale] || value[this.i18n.defaultLocale]!;
        if (!text) {
            console.error('Missing locale');
@@ -269,6 +264,7 @@ export class App {
    public setLocale(locale: string) {
        this.i18n.locale = locale;
        localStorage.setItem('lang', locale);
        this.rx.locale = this.i18n.locale;
    }

    get datasets(): Dataset[] {
@@ -460,7 +456,7 @@ export class App {
        if (this.source && (this.rx.phase === 'render' || this.rx.phase === 'export')) {
            if (this.rx.phase === 'export') {
                const margin = App.EXPORT_WINDOW_GRAB_MARGIN / Math.min(this.state.viewport.zoom, 1),
                    area = this.rx.raw().exportParams.area,
                    area = this.rawRx.exportParams.area,
                    innerW = Math.max(margin / 4, area.w - margin),
                    innerH = Math.max(margin / 4, area.h - margin);
                const inner = area.grow((innerW - area.w) / 2, (innerH - area.h) / 2);
@@ -472,7 +468,7 @@ export class App {
                ...exportedState,
                renderArea: this.source.totalSize,
                lightSource: getLightSourceVec(exportedState),
                exportParams: this.rx.phase === 'export' ? this.rx.raw().exportParams : null,
                exportParams: this.rx.phase === 'export' ? this.rawRx.exportParams : null,
            };

            let join = [...data.lightSource].map((v) => roundTo(v)).join(', ');
@@ -706,26 +702,37 @@ export class App {
            const bl = this.state.viewport.clipToTexture([-0.95, -0.95]);
            let area = Rect.fromCorners(vec2.floor(tr, tr), vec2.ceil(bl, bl));
            area = area.intersection(canvas)!;
            let dezoom = Math.max(-source.zoomDepth, Math.min(0, Math.ceil(Math.log2(this.state.viewport.zoom))));
            // let dezoom = Math.max(-source.zoomDepth, Math.min(0, Math.ceil(Math.log2(this.state.viewport.zoom))));
            this.rx.exportParams.area = area;
            this.rx.exportParams.dezoom = dezoom;
        } else if (from === 'full') {
            this.rx.exportParams.area = canvas;
            this.rx.exportParams.dezoom = 0;
        } else throw new Error(`Unknown argument from = ${from}`);
        this.requestRender();
    }

    private setExportZoom(part: 0 | 1, value: number) {
        console.log('setExportZoom', part, value);
        this.rx.exportParams.zoom[part] = value;
    }

    private getExportSize(): [number, number] {
        const params = this.rx.exportParams;
        return [
            Math.ceil((params.area.w * params.zoom[0]) / params.zoom[1]),
            Math.ceil((params.area.h * params.zoom[0]) / params.zoom[1]),
        ];
    }

    private onExportParamInput(ev: InputEvent) {
        if (this.rx.phase !== 'export') return;
        if (!(ev.currentTarget instanceof HTMLInputElement)) throw new Error();
        const val = ev.currentTarget.valueAsNumber;
        const area = this.rx.raw().exportParams.area,
        const area = this.rawRx.exportParams.area,
            parts = ev.currentTarget.name.split('-'),
            ax = parts[0] === 'x' ? 0 : 1,
            start = area.xy,
            end = [area.x2, area.y2] as [number, number],
            total = this.rx.raw().totalSize,
            total = this.source!.totalSize,
            DIMS = ['start', 'size', 'end'],
            dim = DIMS.indexOf(parts[1]),
            lockDim = DIMS.indexOf(this.rx.exportParams.lockedDim);
@@ -783,16 +790,9 @@ export class App {
                ...exportedState,
                lightSource: getLightSourceVec(exportedState),
                renderArea: source.totalSize,
                exportParams: this.rx.raw().exportParams,
                exportParams: this.rawRx.exportParams,
                dataset,
            };
            let dezoom = 0;
            while (
                data.exportParams.area.w / Math.pow(2, dezoom) > this.maxCanvasSize[0] ||
                data.exportParams.area.h / Math.pow(2, dezoom) > this.maxCanvasSize[1]
            )
                dezoom += 1;
            data.exportParams.dezoom = dezoom;
            this.source = null;
            DownloadManager.withProgressBar<Blob>(
                async (prog) =>
@@ -912,7 +912,7 @@ export class App {
                // Corner
                if (keepRatio) {
                    let diagonal = [prev.w * (1 - qx), prev.h * (1 - qy)] as const;
                    vec2.scale(diff, diagonal, vec2.dot(diff, diagonal) / Math.pow(vec2.len(diagonal), 2));
                    vec2.scale(diff, diagonal, vec2.dot(diff, diagonal) / vec2.len(diagonal) ** 2);
                }
                for (const ax of [0, 1] as const) {
                    setMinMax(ax);
@@ -922,16 +922,17 @@ export class App {
        }
    }

    private getCursor(): string | null {
    private getCursorType(): string | null {
        const { exportWindowHover: xy, dragging } = this.rx.state.pointerState;
        return (
            xy &&
            (xy &&
                [
                    ['nesw-resize', 'ew-resize', 'nwse-resize'],
                    ['ns-resize', null, 'ns-resize'],
                    ['nwse-resize', 'ew-resize', 'nesw-resize'],
            ][xy[0]][xy[1]]
        ) || (dragging ? 'grabbing' : null);
                ][xy[0]][xy[1]]) ||
            (dragging ? 'grabbing' : null)
        );
    }

    private bindPointerEvents() {
@@ -956,7 +957,7 @@ export class App {
        lastMoveEvent = new PointerEvent('pointerleave');
        const onPointerEvent = (ev: PointerEventData | WheelEvent) => {
            const viewport = this.state.viewport!;
            const pointerState = this.rx.pointerState;
            const pointerState = this.rx.state.pointerState;
            if (!this.source) return;

            let lightball = pointerState.dragging
@@ -999,7 +1000,7 @@ export class App {
                this.requestRender();
            };

            const highlight = DEBUG
            const highlight = import.meta.env.DEV
                ? () => {
                      if (!ev.shiftKey || !(ev.buttons & 1) || !this.source || this.rx.phase !== 'render') return false;
                      const texCoord = viewport.screenToTexture(evpos);
@@ -1021,7 +1022,7 @@ export class App {

            const exportWindowPart = (pos: vec2): [0 | 1 | 2, 0 | 1 | 2] | null => {
                if (this.rx.phase !== 'export' || lightball) return null;
                const exPar = this.rx.raw().exportParams;
                const exPar = this.rawRx.exportParams;
                if (!exPar.outerGrab.includes(pos)) return null;

                return [
@@ -1035,7 +1036,7 @@ export class App {
                if (this.state.pointerState.dragging || ev.currentTarget === canvas) {
                    ev.stopPropagation();
                    const delta = (-Math.min(Math.max(Math.abs(ev.deltaY), 10), 50) * Math.sign(ev.deltaY)) / 10;
                    viewport.zoomAround(Math.pow(SCROLL_FACTOR, delta), evpos);
                    viewport.zoomAround(SCROLL_FACTOR ** delta, evpos);
                }
            } else if (ev.type === 'pointerdown') {
                if (pointerState.dragging || !ev.isPrimary || (ev.button !== 0 && ev.button !== 1))
@@ -1051,9 +1052,9 @@ export class App {
                        vec2.sub(
                            pointerState.origin,
                            pointerState.origin,
                            this.rx.raw().exportParams.area.getQuadrantCoords([x, y]),
                            this.rawRx.exportParams.area.getQuadrantCoords([x, y]),
                        );
                        pointerState.originWindow = this.rx.raw().exportParams.area.clone();
                        pointerState.originWindow = this.rawRx.exportParams.area.clone();
                    }
                    doUpdate();
                    window.addEventListener('wheel', onPointerEvent, { passive: false });
+90 −87

File changed.

Preview size limit exceeded, changes collapsed.

+5 −6
Changes for src/gl-program.ts: 5 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -6,7 +6,7 @@ import exportRectVtxSource from './shaders/exportRect.vert';
import exportRectFrgSource from './shaders/exportRect.frag';
import { mat4, vec2, vec3 } from 'gl-matrix';
import { Controls, CONTROLS_SET_UNIFORMS, State } from './control.ts';
import { DEBUG, Exported, GL, roundTo } from './utils.ts';
import { Exported, GL, roundTo } from './utils.ts';
import { Rect, TextureSource, TextureTile } from './tiling.ts';
import { ExportParams } from './app.ts';

@@ -180,7 +180,7 @@ abstract class GLProgramBase<Attribs extends string[], Uniforms extends string[]
    private loadShader(type: GLenum, source: string) {
        const shader = this.gl.createShader(type)!;

        if (DEBUG) source = '#define DEBUG 1\n' + source;
        if (import.meta.env.DEV) source = '#define DEBUG 1\n' + source;

        // Send the source to the shader object
        this.gl.shaderSource(shader, source);
@@ -308,8 +308,8 @@ export class RelightProgram extends GLProgramBase<

    public renderArea(source: TextureSource, data: RenderData, area: Rect, zoom: number) {
        super.render(data);
        const wScale = zoom * 2 / this.gl.drawingBufferWidth,
            hScale = zoom * 2 / this.gl.drawingBufferHeight,
        const wScale = (zoom * 2) / this.gl.drawingBufferWidth,
            hScale = (zoom * 2) / this.gl.drawingBufferHeight,
            proj = mat4.fromScaling(mat4.create(), [wScale, hScale, 1]),
            offset = [-area.x - area.w / 2, -area.y - area.h / 2, 0] as const;
        mat4.translate(proj, proj, offset);
@@ -331,8 +331,7 @@ export class RelightProgram extends GLProgramBase<
            points
                .map((p) => [p[0] * area.w + area.x, p[1] * area.h + area.y] as vec2)
                .map((p): [vec2, vec2] => [p, vec2.transformMat4([0, 0], p, proj)])
                .map(([p1, p2]) =>
                    `  [${roundTo(p1, 3).join(', ')}] => [${roundTo(p2, 3).join(', ')}]`)
                .map(([p1, p2]) => `  [${roundTo(p1, 3).join(', ')}] => [${roundTo(p2, 3).join(', ')}]`)
                .join('\n'),
        );
        this.renderWithProj(source, proj, data);
+5 −4
Changes for src/main.ts: 5 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -44,10 +44,12 @@ try {
            }
        });
    });
    vApp.directive('t', ({ el, exp, modifiers, effect }) => {
    vApp.directive('t', ({ el, exp, get, modifiers, effect }) => {
        effect(() => {
            const content = app.rx.t(exp);
            if (modifiers && modifiers.raw) {
            const content = modifiers && modifiers.dyn ? app.getLocString(get()) : app.rx.t(exp);
            if (!content) {
                el.replaceChildren();
            } else if (modifiers && modifiers.raw) {
                el.innerHTML = content;
            } else {
                el.textContent = content;
@@ -55,7 +57,6 @@ try {
        });
    });
    vApp.mount();
    document.title = app.rx.title;
} catch (e) {
    catchError(true, e as Error);
}
Loading