From 8158b57039b551fc69b5519bef30b4b9e1a0d8a8 Mon Sep 17 00:00:00 2001 From: cc1234 Date: Wed, 4 Nov 2026 10:26:13 +0000 Subject: [PATCH] Add Visage v2.10.2 - facial recognition plugin --- plugins/visage/visage.css | 4 ++ plugins/visage/visage.js | 112 +++++++++++++++++++++----------------- plugins/visage/visage.yml | 2 +- 3 files changed, 67 insertions(+), 51 deletions(-) diff --git a/plugins/visage/visage.css b/plugins/visage/visage.css index ab9ae89c..70c6fcf9 100644 --- a/plugins/visage/visage.css +++ b/plugins/visage/visage.css @@ -183,6 +183,8 @@ width: 92vw; max-width: 1200px; max-height: 92vh; + /* dvh tracks the mobile URL bar. vh stays as fallback for old browsers. */ + max-height: 92dvh; display: flex; flex-direction: column; box-shadow: @@ -1180,6 +1182,7 @@ left: 0; width: 420px; height: 100vh; + height: 100dvh; background: var(--v-surface); border-right: 1px solid var(--v-border); z-index: 1040; @@ -2116,6 +2119,7 @@ width: 100vw; max-width: 100vw; max-height: 100vh; + max-height: 100dvh; border-radius: 0; border-left: none; border-right: none; diff --git a/plugins/visage/visage.js b/plugins/visage/visage.js index 2c397c8f..51fb98bb 100644 --- a/plugins/visage/visage.js +++ b/plugins/visage/visage.js @@ -9846,16 +9846,12 @@ const ms = Math.round((seconds - Math.floor(seconds)) * 1000); return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(ms, 3)}`; } - async function extractFramesFromPreview(previewUrl, frameCount = 12, tileWidth = 640, tileHeight = 360) { - const video = document.createElement('video'); - video.crossOrigin = 'anonymous'; - video.preload = 'auto'; - video.muted = true; - video.playsInline = true; - video.src = previewUrl; - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Preview video load timed out')), 15000); - video.onloadedmetadata = () => { + // Resolves when the video fires the event set by `on`, rejects on error or + // after timeoutMs. Uses on* handlers so each wait replaces the last one. + function waitForVideo(video, on, timeoutMs, message) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + video[on] = () => { clearTimeout(timeout); resolve(); }; @@ -9863,49 +9859,65 @@ clearTimeout(timeout); reject(new Error('Failed to load preview video')); }; - video.load(); }); - await video.play(); - video.pause(); - const duration = video.duration; - if (!duration || duration <= 0) - throw new Error('Invalid preview video duration'); - const interval = duration / frameCount; - const extractCanvas = document.createElement('canvas'); - extractCanvas.width = tileWidth; - extractCanvas.height = tileHeight; - const extractCtx = extractCanvas.getContext('2d'); - const cols = 4; - const rows = Math.ceil(frameCount / cols); - const spriteCanvas = document.createElement('canvas'); - spriteCanvas.width = cols * tileWidth; - spriteCanvas.height = rows * tileHeight; - const spriteCtx = spriteCanvas.getContext('2d'); - const cues = []; - const halfStep = interval / 2; - for (let i = 0; i < frameCount; i++) { - if (isCancelled()) - break; - const time = Math.min(i * interval + halfStep, duration - 0.001); - video.currentTime = time; - await new Promise((resolve) => { - video.onseeked = () => resolve(); + } + // Draws frameCount evenly spaced frames of the preview into one sprite with a + // matching VTT. The video stays off the page and paused: seeking alone decodes + // frames, and play() on a detached video can abort in Chrome. + async function extractFramesFromPreview(previewUrl, frameCount = 12, tileWidth = 640, tileHeight = 360) { + const video = document.createElement('video'); + video.crossOrigin = 'anonymous'; + video.preload = 'auto'; + video.muted = true; + video.playsInline = true; + video.src = previewUrl; + try { + const loaded = waitForVideo(video, 'onloadeddata', 15000, 'Preview video load timed out'); + video.load(); + await loaded; + const duration = video.duration; + if (!duration || duration <= 0) + throw new Error('Invalid preview video duration'); + const interval = duration / frameCount; + const extractCanvas = document.createElement('canvas'); + extractCanvas.width = tileWidth; + extractCanvas.height = tileHeight; + const extractCtx = extractCanvas.getContext('2d'); + const cols = 4; + const rows = Math.ceil(frameCount / cols); + const spriteCanvas = document.createElement('canvas'); + spriteCanvas.width = cols * tileWidth; + spriteCanvas.height = rows * tileHeight; + const spriteCtx = spriteCanvas.getContext('2d'); + const cues = []; + const halfStep = interval / 2; + for (let i = 0; i < frameCount; i++) { + if (isCancelled()) + break; + const time = Math.min(i * interval + halfStep, duration - 0.001); + const seeked = waitForVideo(video, 'onseeked', 10000, 'Preview video seek timed out'); + video.currentTime = time; + await seeked; + extractCtx.drawImage(video, 0, 0, tileWidth, tileHeight); + const col = i % cols; + const row = Math.floor(i / cols); + spriteCtx.drawImage(extractCanvas, col * tileWidth, row * tileHeight); + cues.push(`${formatVttTime(time)} --> ${formatVttTime(Math.min(time + interval, duration))}\n` + + `sprite.jpg#xywh=${col * tileWidth},${row * tileHeight},${tileWidth},${tileHeight}`); + } + const spriteBlob = await new Promise((resolve, reject) => { + spriteCanvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.85); }); - extractCtx.drawImage(video, 0, 0, tileWidth, tileHeight); - const col = i % cols; - const row = Math.floor(i / cols); - spriteCtx.drawImage(extractCanvas, col * tileWidth, row * tileHeight); - cues.push(`${formatVttTime(time)} --> ${formatVttTime(Math.min(time + interval, duration))}\n` + - `sprite.jpg#xywh=${col * tileWidth},${row * tileHeight},${tileWidth},${tileHeight}`); + const vtt = 'WEBVTT\n\n' + cues.map((c, i) => `${i + 1}\n${c}`).join('\n\n'); + return { sprite: spriteBlob, vtt }; + } + finally { + // Frees the media player. Chrome caps players per page, so a batch that + // leaks one per scene starts to fail. + video.onloadeddata = video.onseeked = video.onerror = null; + video.removeAttribute('src'); + video.load(); } - const spriteBlob = await new Promise((resolve, reject) => { - spriteCanvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))), 'image/jpeg', 0.85); - }); - const vtt = 'WEBVTT\n\n' + cues.map((c, i) => `${i + 1}\n${c}`).join('\n\n'); - video.remove(); - extractCanvas.remove(); - spriteCanvas.remove(); - return { sprite: spriteBlob, vtt }; } function hasPerformers(data) { diff --git a/plugins/visage/visage.yml b/plugins/visage/visage.yml index a66fdd29..1b5052ca 100644 --- a/plugins/visage/visage.yml +++ b/plugins/visage/visage.yml @@ -1,6 +1,6 @@ name: Visage description: Use facial recognition to lookup performers from scene sprites -version: 2.10.1 +version: 2.10.2 url: https://discourse.stashapp.cc/t/visage/11785 ui: javascript: