/* * Copyright 2021 Google LLC * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA */ export const supportsFolderSaving = 'showDirectoryPicker' in globalThis; /** @param {number} n */ const pad2 = n => String(n).padStart(2, '0'); /** * Name a frame after the moment it was taken: `20260801-172713.JPG`, or * `20260801-172713_00007.JPG` within a sequence. Sorting by name is then the * same as sorting by capture time, which is what every timelapse tool wants, * and the index keeps sub-second intervals from colliding. * * The camera's own name (IMG_1234) is dropped - it wraps around at 9999 and * restarts on a card format, so it's no basis for ordering a long run. * * @param {string} originalName Used only for its extension (.JPG, .CR2, …). * @param {number} [index] Frame index within a sequence. * @param {Date} [at] */ export function frameName(originalName, index, at = new Date()) { let stamp = `${at.getFullYear()}${pad2(at.getMonth() + 1)}${pad2(at.getDate())}` + `-${pad2(at.getHours())}${pad2(at.getMinutes())}${pad2(at.getSeconds())}`; let ext = /\.[a-z0-9]+$/i.exec(originalName)?.[0] ?? '.jpg'; return index === undefined ? `${stamp}${ext}` : `${stamp}_${String(index + 1).padStart(5, '0')}${ext}`; } /** * Writes captured frames somewhere useful. * * A few hundred `` clicks is a miserable way to land a timelapse on * disk (Chrome prompts for "download multiple files" and every frame goes to * the same Downloads folder), so the default is the File System Access API: * pick a folder once, then frames stream straight into it with a zero-padded * index prefix so they sort in shooting order. */ export class FrameSaver { /** @type {'folder' | 'download' | 'none'} */ mode = 'download'; /** @type {FileSystemDirectoryHandle | null} */ #dir = null; get folderName() { return this.#dir?.name; } get hasFolder() { return this.#dir !== null; } /** * Prompt for an output folder. Returns the folder name, or undefined if the * user dismissed the picker. */ async chooseFolder() { // @ts-ignore - not in the default DOM lib yet. let dir = await globalThis.showDirectoryPicker({ mode: 'readwrite' }); this.#dir = dir; return dir.name; } forgetFolder() { this.#dir = null; } /** * Make sure we still hold write permission - Chrome can drop it between * sessions or after a long idle period, and we'd rather find out before the * sequence starts than 200 frames in. */ async ensureWritable() { if (this.mode !== 'folder') return true; if (!this.#dir) return false; let dir = /** @type {any} */ (this.#dir); if ((await dir.queryPermission({ mode: 'readwrite' })) === 'granted') { return true; } return (await dir.requestPermission({ mode: 'readwrite' })) === 'granted'; } /** * @param {File} file * @param {number} [index] Frame index within a sequence. * @returns {Promise} The name it was saved under. */ async save(file, index) { if (this.mode === 'none') return; let name = frameName(file.name, index); if (this.mode === 'folder') { await this.#saveToFolder(file, name); } else { this.#download(file, name); } return name; } /** * @param {File} file * @param {string} name */ async #saveToFolder(file, name) { if (!this.#dir) throw new Error('No output folder selected'); let handle = await this.#dir.getFileHandle(name, { create: true }); let writable = await handle.createWritable(); try { await writable.write(file); } finally { await writable.close(); } } /** * @param {File} file * @param {string} name */ #download(file, name) { let url = URL.createObjectURL(file); Object.assign(document.createElement('a'), { download: name, href: url }).click(); // Revoking synchronously can race the download in some Chrome versions. setTimeout(() => URL.revokeObjectURL(url), 60_000); } } /** * Holds a screen wake lock for the duration of a sequence. Chrome throttles * timers hard in backgrounded tabs, which would wreck the interval timing, so * keeping the display (and tab) alive matters here. */ export class WakeLock { /** @type {any} */ #lock = null; async acquire() { if (this.#lock || !('wakeLock' in navigator)) return; try { // @ts-ignore this.#lock = await navigator.wakeLock.request('screen'); this.#lock.addEventListener('release', () => { this.#lock = null; }); // Chrome drops the lock whenever the tab is hidden; re-take it on return. document.addEventListener('visibilitychange', this.#reacquire); } catch (err) { console.warn('Could not acquire wake lock:', err); } } #reacquire = () => { if (document.visibilityState === 'visible' && !this.#lock) { this.acquire(); } }; async release() { document.removeEventListener('visibilitychange', this.#reacquire); let lock = this.#lock; this.#lock = null; await lock?.release?.(); } }