Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions plugins/visage/visage.css
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2116,6 +2119,7 @@
width: 100vw;
max-width: 100vw;
max-height: 100vh;
max-height: 100dvh;
border-radius: 0;
border-left: none;
border-right: none;
Expand Down
112 changes: 62 additions & 50 deletions plugins/visage/visage.js
Original file line number Diff line number Diff line change
Expand Up @@ -9846,66 +9846,78 @@
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();
};
video.onerror = () => {
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) {
Expand Down
2 changes: 1 addition & 1 deletion plugins/visage/visage.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Loading