diff --git a/plugins/MosaicPoster/.gitignore b/plugins/MosaicPoster/.gitignore new file mode 100644 index 00000000..f7f22d61 --- /dev/null +++ b/plugins/MosaicPoster/.gitignore @@ -0,0 +1,5 @@ +# Generated contact sheets (disposable cache, regenerated on demand) +generated/ +# Python bytecode +__pycache__/ +*.pyc diff --git a/plugins/MosaicPoster/MosaicPoster.css b/plugins/MosaicPoster/MosaicPoster.css new file mode 100644 index 00000000..7a257ffe --- /dev/null +++ b/plugins/MosaicPoster/MosaicPoster.css @@ -0,0 +1,125 @@ +/* Mosaic Poster — mosaic overlay + toggle/size buttons on the scene detail poster */ + +/* Mosaic layer: covers the cover art underneath. When .mp-seekable, tapping a + cell seeks the video to that point in time; otherwise clicks pass through. */ +.vjs-poster .mp-overlay { + position: absolute; + inset: 0; + background-color: #000; + background-repeat: no-repeat; + background-position: center; + background-size: contain; + pointer-events: none; + z-index: 2; +} +.vjs-poster .mp-overlay.mp-seekable { + pointer-events: auto; + cursor: pointer; +} +/* hovered-cell highlight (desktop) — red, translucent */ +.vjs-poster .mp-overlay .mp-cell-hi { + position: absolute; + pointer-events: none; + box-sizing: border-box; + border: 2px solid rgba(255, 45, 45, 0.95); + background: rgba(255, 40, 40, 0.28); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55) inset; + transition: left 0.05s linear, top 0.05s linear, width 0.05s linear, height 0.05s linear; +} +/* keep the big play button above the mosaic so its centre still plays from the + start; the surrounding cells seek */ +.video-js .vjs-big-play-button { + z-index: 3; +} + +/* Mosaic on/off toggle: "Mosaic" label + a switch showing the current state + (top-right of the poster, pre-playback) */ +.vjs-poster .mp-toggle { + position: absolute; + top: 10px; + right: 10px; + z-index: 3; + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 7px; + background: rgba(0, 0, 0, 0.62); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 6px; + padding: 6px 9px; + font-size: 12px; + line-height: 1; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.12s ease, background 0.12s ease; + -webkit-user-select: none; + user-select: none; +} +.vjs-poster .mp-toggle:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.8); +} +.vjs-poster .mp-toggle-label { font-weight: 600; } +/* loading spinner shown in the toggle while a sheet is being generated */ +.vjs-poster .mp-spin { + width: 13px; + height: 13px; + flex: none; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: #fff; + border-radius: 50%; + animation: mp-spin 0.7s linear infinite; +} +@keyframes mp-spin { to { transform: rotate(360deg); } } +/* switch track */ +.vjs-poster .mp-switch { + position: relative; + width: 34px; + height: 18px; + flex: none; + border-radius: 9px; + background: rgba(255, 255, 255, 0.3); + transition: background 0.15s ease; +} +.vjs-poster .mp-toggle.mp-on .mp-switch { background: #4caf50; } +/* switch knob */ +.vjs-poster .mp-knob { + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + transition: left 0.15s ease; +} +.vjs-poster .mp-toggle.mp-on .mp-knob { left: 18px; } + +/* Grid-size button (top-left of the poster, shown only in mosaic mode) */ +.vjs-poster .mp-size { + position: absolute; + top: 10px; + left: 10px; + z-index: 3; + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(0, 0, 0, 0.62); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 5px; + padding: 5px 9px; + font-size: 12px; + line-height: 1; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.12s ease, background 0.12s ease; + -webkit-user-select: none; + user-select: none; +} +.vjs-poster .mp-size:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.8); +} diff --git a/plugins/MosaicPoster/MosaicPoster.js b/plugins/MosaicPoster/MosaicPoster.js new file mode 100644 index 00000000..57fc50f0 --- /dev/null +++ b/plugins/MosaicPoster/MosaicPoster.js @@ -0,0 +1,800 @@ +/* ========================================================================= + * Mosaic Poster (Stash UI plugin) + * + * Replaces the pre-playback poster on the scene detail page (/scenes/{id}) + * with a 5x5 contact sheet (a whole-video overview) sampled evenly across + * the video. + * + * - Hi-res sheet: generated from the real video by the backend + * (MosaicPoster.py) via runPluginOperation, and served by Stash at + * /plugin/MosaicPoster/assets/generated/{oshash}.jpg. Generation runs + * Python on demand (2-3s the first time, cached afterwards). No standalone + * daemon, no fixed port, no OS-specific service. + * - While a sheet is being generated for the first time, the cover art stays + * visible and a small loading spinner appears next to the toggle; the mosaic + * is shown only once the hi-res sheet is ready. + * - The button at the top-right of the poster toggles cover art <-> mosaic in + * one click. The choice is stored in localStorage and remembered across all + * scenes (default: mosaic ON). + * - Overlay approach: the cover art is kept underneath; the mosaic layer is + * added/removed on top. + * - Never touches cover art on list/card views. + * ========================================================================= */ +(function () { + "use strict"; + + var DEFAULT_ON = true; + var DEFAULT_SEEK = true; // tapping a mosaic cell seeks the video there + var DEFAULT_HIRES = false; // upgrade the sprite sheet to a generated hi-res one + var SIZES = [3, 4, 5, 6, 7, 8]; // selectable N x N grids (16:9 cells -> 16:9 sheet) + var DEFAULT_SIZE = 5; + var PLUGIN_ID = "MosaicPoster"; + + // Both the on/off default and the grid size live in plugin settings (editable + // in Settings > Plugins) and are the source of truth, shared across devices. + // Cached here for synchronous reads; the poster buttons write them back via + // configurePlugin so the Settings page and the buttons stay in sync. + var currentOn = DEFAULT_ON; + var currentGrid = DEFAULT_SIZE; + var currentSeek = DEFAULT_SEEK; + var currentHiRes = DEFAULT_HIRES; + var settingsLoaded = false; // don't kick generation until the real grid size is known + + // ---- i18n: only the JS strings can be localised (yml setting labels can't). + // Keyed by Stash's interface language code, lower-cased. English is the + // default: any language without an entry falls back to it. To add a language, + // add an entry keyed by its lower-cased Stash code (e.g. "pt-br") or base code + // (e.g. "de"); lookup tries the full code first, then the base code, then en. + var DEFAULT_LANG = "en"; + // Covers every language Stash ships. Keyed by base language code (zh split + // into zh-cn / zh-tw). Non-English strings are best-effort — corrections from + // native speakers are welcome. + var STRINGS = { + en: { mosaic: "Mosaic", on: "Mosaic on — click to show cover art", off: "Mosaic off — click to show mosaic", size: "Mosaic grid size (click to change)" }, + af: { mosaic: "Mosaïek", on: "Mosaïek aan — klik om die omslag te wys", off: "Mosaïek af — klik om die mosaïek te wys", size: "Mosaïek-roostergrootte (klik om te verander)" }, + ar: { mosaic: "فسيفساء", on: "الفسيفساء مفعّلة — انقر لعرض صورة الغلاف", off: "الفسيفساء متوقفة — انقر لعرض الفسيفساء", size: "حجم شبكة الفسيفساء (انقر للتغيير)" }, + bg: { mosaic: "Мозайка", on: "Мозайката е включена — щракнете, за да покажете корицата", off: "Мозайката е изключена — щракнете, за да покажете мозайката", size: "Размер на мрежата на мозайката (щракнете, за да промените)" }, + bn: { mosaic: "মোজাইক", on: "মোজাইক চালু — কভার দেখাতে ক্লিক করুন", off: "মোজাইক বন্ধ — মোজাইক দেখাতে ক্লিক করুন", size: "মোজাইক গ্রিডের আকার (পরিবর্তন করতে ক্লিক করুন)" }, + ca: { mosaic: "Mosaic", on: "Mosaic activat — fes clic per mostrar la portada", off: "Mosaic desactivat — fes clic per mostrar el mosaic", size: "Mida de la quadrícula del mosaic (fes clic per canviar)" }, + cs: { mosaic: "Mozaika", on: "Mozaika zapnuta — kliknutím zobrazíte obal", off: "Mozaika vypnuta — kliknutím zobrazíte mozaiku", size: "Velikost mřížky mozaiky (kliknutím změníte)" }, + da: { mosaic: "Mosaik", on: "Mosaik til — klik for at vise omslaget", off: "Mosaik fra — klik for at vise mosaikken", size: "Mosaikkens gitterstørrelse (klik for at ændre)" }, + de: { mosaic: "Mosaik", on: "Mosaik an — klicken, um das Cover anzuzeigen", off: "Mosaik aus — klicken, um das Mosaik anzuzeigen", size: "Mosaik-Rastergröße (zum Ändern klicken)" }, + es: { mosaic: "Mosaico", on: "Mosaico activado — haz clic para ver la carátula", off: "Mosaico desactivado — haz clic para ver el mosaico", size: "Tamaño de la cuadrícula del mosaico (haz clic para cambiar)" }, + et: { mosaic: "Mosaiik", on: "Mosaiik sees — klõpsake kaanepildi kuvamiseks", off: "Mosaiik väljas — klõpsake mosaiigi kuvamiseks", size: "Mosaiigi ruudustiku suurus (klõpsake muutmiseks)" }, + fa: { mosaic: "موزاییک", on: "موزاییک روشن — برای نمایش کاور کلیک کنید", off: "موزاییک خاموش — برای نمایش موزاییک کلیک کنید", size: "اندازه شبکه موزاییک (برای تغییر کلیک کنید)" }, + fi: { mosaic: "Mosaiikki", on: "Mosaiikki päällä — näytä kansikuva napsauttamalla", off: "Mosaiikki pois — näytä mosaiikki napsauttamalla", size: "Mosaiikin ruudukon koko (muuta napsauttamalla)" }, + fr: { mosaic: "Mosaïque", on: "Mosaïque activée — cliquez pour afficher la jaquette", off: "Mosaïque désactivée — cliquez pour afficher la mosaïque", size: "Taille de la grille de la mosaïque (cliquez pour changer)" }, + hi: { mosaic: "मोज़ेक", on: "मोज़ेक चालू — कवर दिखाने के लिए क्लिक करें", off: "मोज़ेक बंद — मोज़ेक दिखाने के लिए क्लिक करें", size: "मोज़ेक ग्रिड आकार (बदलने के लिए क्लिक करें)" }, + hr: { mosaic: "Mozaik", on: "Mozaik uključen — kliknite za prikaz naslovnice", off: "Mozaik isključen — kliknite za prikaz mozaika", size: "Veličina mreže mozaika (kliknite za promjenu)" }, + hu: { mosaic: "Mozaik", on: "Mozaik be — kattintson a borító megjelenítéséhez", off: "Mozaik ki — kattintson a mozaik megjelenítéséhez", size: "Mozaik rácsméret (kattintson a módosításhoz)" }, + id: { mosaic: "Mozaik", on: "Mozaik aktif — klik untuk menampilkan sampul", off: "Mozaik nonaktif — klik untuk menampilkan mozaik", size: "Ukuran kisi mozaik (klik untuk mengubah)" }, + it: { mosaic: "Mosaico", on: "Mosaico attivo — clicca per mostrare la copertina", off: "Mosaico disattivato — clicca per mostrare il mosaico", size: "Dimensione della griglia del mosaico (clicca per cambiare)" }, + ja: { mosaic: "モザイク", on: "モザイク表示中 — クリックでジャケット", off: "モザイク非表示 — クリックでモザイク", size: "モザイクのマス数(クリックで変更)" }, + ko: { mosaic: "모자이크", on: "모자이크 켜짐 — 클릭하면 표지 표시", off: "모자이크 꺼짐 — 클릭하면 모자이크 표시", size: "모자이크 격자 크기 (클릭하여 변경)" }, + lt: { mosaic: "Mozaika", on: "Mozaika įjungta — spustelėkite, kad būtų rodomas viršelis", off: "Mozaika išjungta — spustelėkite, kad būtų rodoma mozaika", size: "Mozaikos tinklelio dydis (spustelėkite, kad pakeistumėte)" }, + lv: { mosaic: "Mozaīka", on: "Mozaīka ieslēgta — noklikšķiniet, lai parādītu vāku", off: "Mozaīka izslēgta — noklikšķiniet, lai parādītu mozaīku", size: "Mozaīkas režģa izmērs (noklikšķiniet, lai mainītu)" }, + nb: { mosaic: "Mosaikk", on: "Mosaikk på — klikk for å vise omslaget", off: "Mosaikk av — klikk for å vise mosaikken", size: "Mosaikkens rutenettstørrelse (klikk for å endre)" }, + ne: { mosaic: "मोजाइक", on: "मोजाइक अन — कभर देखाउन क्लिक गर्नुहोस्", off: "मोजाइक अफ — मोजाइक देखाउन क्लिक गर्नुहोस्", size: "मोजाइक ग्रिड आकार (परिवर्तन गर्न क्लिक गर्नुहोस्)" }, + nl: { mosaic: "Mozaïek", on: "Mozaïek aan — klik om de hoes te tonen", off: "Mozaïek uit — klik om het mozaïek te tonen", size: "Mozaïek-rastergrootte (klik om te wijzigen)" }, + nn: { mosaic: "Mosaikk", on: "Mosaikk på — klikk for å vise omslaget", off: "Mosaikk av — klikk for å vise mosaikken", size: "Mosaikk-rutenettstorleik (klikk for å endre)" }, + pl: { mosaic: "Mozaika", on: "Mozaika włączona — kliknij, aby pokazać okładkę", off: "Mozaika wyłączona — kliknij, aby pokazać mozaikę", size: "Rozmiar siatki mozaiki (kliknij, aby zmienić)" }, + pt: { mosaic: "Mosaico", on: "Mosaico ativado — clique para mostrar a capa", off: "Mosaico desativado — clique para mostrar o mosaico", size: "Tamanho da grade do mosaico (clique para alterar)" }, + ro: { mosaic: "Mozaic", on: "Mozaic activat — dați clic pentru a afișa coperta", off: "Mozaic dezactivat — dați clic pentru a afișa mozaicul", size: "Dimensiunea grilei mozaicului (dați clic pentru a schimba)" }, + ru: { mosaic: "Мозаика", on: "Мозаика включена — нажмите, чтобы показать обложку", off: "Мозаика выключена — нажмите, чтобы показать мозаику", size: "Размер сетки мозаики (нажмите, чтобы изменить)" }, + sk: { mosaic: "Mozaika", on: "Mozaika zapnutá — kliknutím zobrazíte obal", off: "Mozaika vypnutá — kliknutím zobrazíte mozaiku", size: "Veľkosť mriežky mozaiky (kliknutím zmeníte)" }, + sv: { mosaic: "Mosaik", on: "Mosaik på — klicka för att visa omslaget", off: "Mosaik av — klicka för att visa mosaiken", size: "Mosaikens rutnätsstorlek (klicka för att ändra)" }, + th: { mosaic: "โมเสก", on: "เปิดโมเสก — คลิกเพื่อแสดงปก", off: "ปิดโมเสก — คลิกเพื่อแสดงโมเสก", size: "ขนาดตารางโมเสก (คลิกเพื่อเปลี่ยน)" }, + tr: { mosaic: "Mozaik", on: "Mozaik açık — kapağı göstermek için tıklayın", off: "Mozaik kapalı — mozaiği göstermek için tıklayın", size: "Mozaik ızgara boyutu (değiştirmek için tıklayın)" }, + uk: { mosaic: "Мозаїка", on: "Мозаїка увімкнена — натисніть, щоб показати обкладинку", off: "Мозаїка вимкнена — натисніть, щоб показати мозаїку", size: "Розмір сітки мозаїки (натисніть, щоб змінити)" }, + ur: { mosaic: "موزیک", on: "موزیک آن — کور دکھانے کے لیے کلک کریں", off: "موزیک آف — موزیک دکھانے کے لیے کلک کریں", size: "موزیک گرڈ سائز (تبدیل کرنے کے لیے کلک کریں)" }, + vi: { mosaic: "Khảm", on: "Bật khảm — nhấp để hiện ảnh bìa", off: "Tắt khảm — nhấp để hiện khảm", size: "Kích thước lưới khảm (nhấp để thay đổi)" }, + "zh-cn": { mosaic: "马赛克", on: "马赛克开 — 点击显示封面", off: "马赛克关 — 点击显示马赛克", size: "马赛克网格大小(点击更改)" }, + "zh-tw": { mosaic: "馬賽克", on: "馬賽克開 — 點擊顯示封面", off: "馬賽克關 — 點擊顯示馬賽克", size: "馬賽克網格大小(點擊變更)" }, + }; + function pickStrings(lang) { + lang = (lang || "").toLowerCase(); // e.g. "ja-jp" + if (STRINGS[lang]) return STRINGS[lang]; // exact, e.g. "pt-br" + var base = lang.split("-")[0]; // e.g. "ja" + if (STRINGS[base]) return STRINGS[base]; + return STRINGS[DEFAULT_LANG]; + } + var T = STRINGS[DEFAULT_LANG]; + + // Cache/URL key for a given scene sheet at a given grid size. + function sheetKey(oshash, n) { return oshash + "_" + n + "x" + n; } + + // Generated sheets are served by Stash as plugin assets (same origin). + function assetUrl(oshash, n) { + return "/plugin/" + PLUGIN_ID + "/assets/generated/" + sheetKey(oshash, n) + ".jpg"; + } + + // Call the generation backend (Stash runs the Python). Promise. + function runOp(args) { + return fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + query: "mutation($id:ID!,$a:Map){runPluginOperation(plugin_id:$id,args:$a)}", + variables: { id: PLUGIN_ID, a: args }, + }), + }) + .then(function (r) { return r.json(); }) + .then(function (j) { return (j && j.data && j.data.runPluginOperation) || null; }) + .catch(function () { return null; }); + } + + var infoCache = new Map(); // sceneId -> Promise<{oshash,duration,sprite}|null> + var spriteCache = new Map(); // spriteUrl+"|"+n -> Promise<{url,times,aspect}|null> + var probeCache = new Map(); // sheetKey -> Promise (existence check, no generation) + var hiresReady = new Set(); // sheetKey -> confirmed generated (no need to re-probe) + var genInFlight = new Map(); // sheetKey -> Promise (generation in progress; dedupes) + + /* ---------- state ---------- + * currentOn = the global default (plugin setting, shared across devices). + * Each scene may override it locally via the poster button; the override is + * stored per scene in localStorage and wins over the default for that scene. */ + var OV_PREFIX = "MosaicPoster.ov."; // per-scene override key prefix + + function overrideFor(id) { + if (!id) return null; + var v = localStorage.getItem(OV_PREFIX + id); + return v === null ? null : (v === "1"); + } + function sceneEnabled(id) { + var o = overrideFor(id); + return o === null ? currentOn : o; + } + function isEnabled() { return sceneEnabled(currentSceneId()); } + // The poster button toggles THIS scene only; the global default is unchanged. + function setEnabled(on) { + var id = currentSceneId(); + if (id) localStorage.setItem(OV_PREFIX + id, on ? "1" : "0"); + } + + function clampSize(v) { + var n = parseInt(v, 10); + if (isNaN(n)) return null; + return Math.max(SIZES[0], Math.min(SIZES[SIZES.length - 1], n)); + } + function currentSize() { return currentGrid; } + function cycleSize() { + var i = SIZES.indexOf(currentSize()); + var next = SIZES[(i + 1) % SIZES.length]; + currentGrid = next; + persistGrid(next); // write back to the plugin setting (keeps Settings page in sync) + return next; + } + + function asBool(v) { + if (v === true || v === false) return v; + if (v === "true") return true; + if (v === "false") return false; + return null; + } + + // Pick the string table from Stash's UI language (e.g. "ja-JP" -> Japanese). + function loadLanguage() { + return fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ query: "{configuration{interface{language}}}" }), + }) + .then(function (r) { return r.json(); }) + .then(function (j) { + try { T = pickStrings(j.data.configuration.interface.language); } catch (e) {} + }) + .catch(function () {}); + } + + function readSettings() { + return fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ query: "{configuration{plugins}}" }), + }) + .then(function (r) { return r.json(); }) + .then(function (j) { + try { return (j.data.configuration.plugins || {})[PLUGIN_ID] || {}; } + catch (e) { return {}; } + }) + .catch(function () { return {}; }); + } + + // Read on/off + grid size from the plugin settings (source of truth) into the + // caches. When a setting is unset, initialise it to the default so the + // Settings page reflects the actual behaviour. + function loadSettings() { + return readSettings().then(function (cfg) { + var on = asBool(cfg.defaultOn); + if (on !== null) currentOn = on; + var n = clampSize(cfg.gridSize); + if (n !== null) currentGrid = n; + var sk = asBool(cfg.tapToSeek); + if (sk !== null) currentSeek = sk; + var hr = asBool(cfg.hiResUpgrade); + if (hr !== null) currentHiRes = hr; + var patch = {}; + if (on === null) patch.defaultOn = currentOn; + if (n === null) patch.gridSize = currentGrid; + if (sk === null) patch.tapToSeek = currentSeek; + if (hr === null) patch.hiResUpgrade = currentHiRes; + if (Object.keys(patch).length) configure(patch); // initialise unset setting(s) + settingsLoaded = true; + }); + } + + // configurePlugin REPLACES the whole settings map, so read-modify-write: merge + // the patch onto the current settings (preserving cacheMax and anything else). + function configure(patch) { + return readSettings().then(function (cur) { + var merged = Object.assign({}, cur, patch); + return fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + query: "mutation($id:ID!,$in:Map!){configurePlugin(plugin_id:$id,input:$in)}", + variables: { id: PLUGIN_ID, in: merged }, + }), + }); + }).catch(function () {}); + } + function persistGrid(n) { return configure({ gridSize: n }); } + + function currentSceneId() { + var m = location.pathname.match(/^\/scenes\/(\d+)(?:\/|$)/); + return m ? m[1] : null; + } + + /* ---------- scene info (oshash + duration + sprite) ---------- */ + function fetchInfo(id) { + if (infoCache.has(id)) return infoCache.get(id); + var p = fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + query: "query($id:ID!){findScene(id:$id){paths{sprite} files{duration fingerprints{type value}}}}", + variables: { id: id }, + }), + }) + .then(function (r) { return r.json(); }) + .then(function (j) { + try { + var sc = j.data.findScene; + var f0 = sc.files[0] || {}, fps = f0.fingerprints || []; + var oshash = null; + for (var i = 0; i < fps.length; i++) { + if (fps[i].type === "oshash") { oshash = fps[i].value; break; } + } + return { oshash: oshash, duration: f0.duration || 0, + sprite: (sc.paths && sc.paths.sprite) || null }; + } catch (e) { return null; } + }) + .catch(function () { infoCache.delete(id); return null; }); + infoCache.set(id, p); + return p; + } + + function loadImage(src, crossOrigin) { + return new Promise(function (resolve, reject) { + var img = new Image(); + if (crossOrigin) img.crossOrigin = "anonymous"; + img.onload = function () { resolve(img); }; + img.onerror = reject; + img.src = src; + }); + } + + /* ---------- sheet from Stash's existing scene sprite (no generation) ---------- + * Stash already generates a sprite + WebVTT for each scanned scene (used for + * the seekbar hover preview). We reuse them: parse the VTT for each thumb's + * timestamp and region, then splice the frames into an N x N sheet on a + * canvas — entirely client-side, no ffmpeg, no extra storage. Falls back to + * on-demand generation only when a scene has no sprite. */ + function vttSeconds(s) { + var parts = String(s).trim().split(":"), sec = 0; + for (var i = 0; i < parts.length; i++) sec = sec * 60 + parseFloat(parts[i] || 0); + return isNaN(sec) ? 0 : sec; + } + function fetchSpriteCues(vttUrl) { + return fetch(vttUrl, { credentials: "same-origin" }) + .then(function (r) { return r.ok ? r.text() : ""; }) + .then(function (txt) { + var cues = [], lines = txt.split(/\r?\n/); + for (var i = 0; i < lines.length; i++) { + var arrow = lines[i].indexOf("-->"); + if (arrow < 0) continue; + var start = vttSeconds(lines[i].slice(0, arrow)); + for (var j = i + 1; j < lines.length && lines[j].indexOf("-->") < 0; j++) { + var m = lines[j].match(/#xywh=(\d+),(\d+),(\d+),(\d+)/); + if (m) { cues.push({ t: start, x: +m[1], y: +m[2], w: +m[3], h: +m[4] }); break; } + } + } + return cues; + }); + } + function pickIndices(n, k) { + if (n <= k) { var all = []; for (var i = 0; i < n; i++) all.push(i); return all; } + var out = []; for (var j = 0; j < k; j++) out.push(Math.round((j * (n - 1)) / (k - 1))); + return out; + } + // Build the N x N sheet from the sprite/VTT. Resolves {url, times, aspect} or + // null (no sprite / empty VTT). times[i] = real timestamp of cell i (for seek). + function buildSpriteSheet(spriteUrl, n) { + if (!spriteUrl) return Promise.resolve(null); + var key = spriteUrl + "|" + n; + if (spriteCache.has(key)) return spriteCache.get(key); + var cells = n * n; + var vttUrl = spriteUrl.replace(/_sprite\.jpg(\?.*)?$/, "_thumbs.vtt"); + var p = Promise.all([fetchSpriteCues(vttUrl), loadImage(spriteUrl)]).then(function (res) { + var cues = res[0], sprite = res[1]; + if (!cues.length) return null; + var idx = pickIndices(cues.length, cells); + var cw = cues[0].w, ch = cues[0].h; + var canvas = document.createElement("canvas"); + canvas.width = n * cw; canvas.height = n * ch; + var ctx = canvas.getContext("2d"); + ctx.fillStyle = "#000"; ctx.fillRect(0, 0, canvas.width, canvas.height); + var times = []; + for (var i = 0; i < idx.length; i++) { + var c = cues[idx[i]]; + ctx.drawImage(sprite, c.x, c.y, c.w, c.h, (i % n) * cw, Math.floor(i / n) * ch, cw, ch); + times.push(c.t); + } + return { url: canvas.toDataURL("image/jpeg", 0.85), times: times, aspect: cw / ch }; + }).catch(function () { spriteCache.delete(key); return null; }); + spriteCache.set(key, p); + return p; + } + + /* ---------- hi-res sheet (Stash-served + generation backend) ---------- + * probe and generate are split. On navigation we first probeHires (just + * checks whether the sheet already exists); if so we show it immediately. + * Only when it is missing do we keep the cover art visible, show a loading + * spinner, and generate the sheet in the background. */ + + // Check whether a generated sheet already exists (without generating it). + // Exists -> resolve URL. Missing -> null. + // Just loads the Stash-served static URL as an image (same origin). + function probeHires(oshash, n) { + if (!oshash) return Promise.resolve(null); + var key = sheetKey(oshash, n); + if (hiresReady.has(key)) return Promise.resolve(assetUrl(oshash, n)); + if (probeCache.has(key)) return probeCache.get(key); + var url = assetUrl(oshash, n); + var p = loadImage(url) + .then(function () { hiresReady.add(key); return url; }) // confirmed present + .catch(function () { probeCache.delete(key); return null; }); // missing -> re-checkable + probeCache.set(key, p); + return p; + } + + // When missing, ask the backend to generate it, then return the URL (2-3s). + // In-flight generations are de-duped so repeated renders don't re-request. + function generateHires(id, oshash, n) { + if (!oshash) return Promise.resolve(null); + var key = sheetKey(oshash, n); + if (hiresReady.has(key)) return Promise.resolve(assetUrl(oshash, n)); + if (genInFlight.has(key)) return genInFlight.get(key); + var url = assetUrl(oshash, n); + var p = runOp({ mode: "generate", scene_id: id, n: n }) + .then(function () { return loadImage(url); }) // load the served URL after generation + .then(function () { hiresReady.add(key); return url; }) + .catch(function () { return null; }) // generation failed -> keep the cover + .then(function (res) { genInFlight.delete(key); return res; }); + genInFlight.set(key, p); + return p; + } + + /* ---------- seek: tap a mosaic cell to jump there ---------- */ + // Seek the video.js player (or the raw