From d9f007edd2e4ae337a634058076d68ac581d3ece Mon Sep 17 00:00:00 2001 From: HIROMASA SUZUKI Date: Thu, 16 Jul 2026 21:05:16 +0900 Subject: [PATCH 1/2] Add Mosaic Poster plugin Replaces the pre-playback scene poster with an N x N contact sheet of the whole video. Toggle cover art / mosaic, selectable grid size (3-8), and tap a cell to seek to that point. Daemon-free: sheets are generated on demand via runPluginOperation and served by Stash. Localized for all Stash UI languages. Co-Authored-By: Claude Opus 4.8 --- plugins/MosaicPoster/.gitignore | 5 + plugins/MosaicPoster/MosaicPoster.css | 125 +++++ plugins/MosaicPoster/MosaicPoster.js | 688 ++++++++++++++++++++++++++ plugins/MosaicPoster/MosaicPoster.py | 333 +++++++++++++ plugins/MosaicPoster/MosaicPoster.yml | 47 ++ plugins/MosaicPoster/README.md | 114 +++++ plugins/MosaicPoster/preview.png | Bin 0 -> 276349 bytes 7 files changed, 1312 insertions(+) create mode 100644 plugins/MosaicPoster/.gitignore create mode 100644 plugins/MosaicPoster/MosaicPoster.css create mode 100644 plugins/MosaicPoster/MosaicPoster.js create mode 100644 plugins/MosaicPoster/MosaicPoster.py create mode 100644 plugins/MosaicPoster/MosaicPoster.yml create mode 100644 plugins/MosaicPoster/README.md create mode 100644 plugins/MosaicPoster/preview.png 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..0f9a7fa6 --- /dev/null +++ b/plugins/MosaicPoster/MosaicPoster.js @@ -0,0 +1,688 @@ +/* ========================================================================= + * 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 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 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}|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 patch = {}; + if (on === null) patch.defaultOn = currentOn; + if (n === null) patch.gridSize = currentGrid; + if (sk === null) patch.tapToSeek = currentSeek; + 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) ---------- */ + 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){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 }; + } 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; + }); + } + + /* ---------- 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