fix(assets): improve grid reload and scroll behavior

- Adjusting the infinite scroll loading point to 4 card rows from the bottom
- Improving grid state restoration after reloads
- Enhancing the early fetch mechanism for faster initial loads
- Optimizing execution runtime to handle large numbers of assets efficiently
- Updating viewer and run button behavior for better integration with ComfyUI
- Fix various bugs and edge cases
This commit is contained in:
Ewald
2026-04-20 23:10:42 +02:00
parent 4e80b95bbc
commit b2ef3d0f80
40 changed files with 18233 additions and 16989 deletions
+19
View File
@@ -2,6 +2,25 @@
All notable changes to this project are documented in this file.
## [Unreleased]
### 🐛 Bug Fixes & Performance
- **Bug Fixes**: Various bug fixes
- **Performance and Fluidity**: Performance and fluidity improvements
### ✨ Improved
- **Concatenate Support**: Improved concatenate support for default and custom nodes — by Forsion07
### 🔌 API & Integrations
- **Api Node Helpers**: Add support helpers for Api Node
- **Ernie Image Helper**: Add support helper for Ernie Image
### ⚙️ Settings & Defaults
- **Live Stream disabled by default**: MFV Live Stream (green button) is now OFF by default; can be re-enabled permanently via Settings → Majoor Assets Manager › Viewer
- **Live Stream & KSampler Preview configurable by default**: Both toggles can be set as the default state via Settings → Viewer; when Live Stream is active, clicking a Load Image node or a generation end automatically opens the Floating Viewer
---
## [2.4.5] - 2026-04-10
### 🎉 New Features — Floating Viewer
+4 -3
View File
@@ -58,12 +58,13 @@ export const APP_DEFAULTS = Object.freeze({
BADGE_DUPLICATE_ALERT_COLOR: "#FF1744",
// Pagination
DEFAULT_PAGE_SIZE: 200,
DEFAULT_PAGE_SIZE: 80,
// Upper bound for a single list request. The backend allows more, but very large
// pages can create huge DOM batches; keep this as a safety cap.
MAX_PAGE_SIZE: 2000,
// Prefetch next page immediately after initial load for faster scrolling
// Prefetch next page after the first paint so initial grid render stays responsive
PREFETCH_NEXT_PAGE: true,
PREFETCH_NEXT_PAGE_DELAY_MS: 700,
// Client-side default for search request limit (user-adjustable in settings)
SEARCH_DEFAULT_LIMIT: 500,
INFINITE_SCROLL_ENABLED: true,
@@ -83,7 +84,7 @@ export const APP_DEFAULTS = Object.freeze({
VIEWER_PAUSE_DURING_EXECUTION: true,
FLOATING_VIEWER_PAUSE_DURING_EXECUTION: false,
MFV_SIDEBAR_POSITION: "right",
MFV_LIVE_DEFAULT: true,
MFV_LIVE_DEFAULT: false,
MFV_PREVIEW_DEFAULT: true,
MFV_PREVIEW_METHOD: "taesd",
+18
View File
@@ -396,6 +396,7 @@ const DICTIONARY = {
"tags.title": "Tags: {tags}",
"tags.label": "Tags",
"tags.addLabel": "Add tag",
"tags.remove": "Remove tag",
"tags.suggestions": "Tag suggestions",
"label.messages": "Messages",
"label.readMe": "Read Me",
@@ -639,6 +640,14 @@ const DICTIONARY = {
"msg.shortcuts.section.mfv": "Floating Viewer",
"msg.shortcuts.section.video": "Video Playback",
"msg.category.release": "Release",
"msg.whatsNew.title.version246": "New Version 2.4.6",
"msg.whatsNew.body.version246":
"Version 2.4.6 released: Various bug fixes and performance & fluidity improvements. Improved concatenate support for default and custom nodes (by Forsion07). Added support helpers for Api Node and Ernie Image. Live Stream in Floating Viewer is now disabled by default. See CHANGELOG for details.",
"label.changelog": "Changelog",
"label.settingsGuide": "Settings Guide",
"msg.tip.title.mfvLivePreviewDefaults": "Tip — Floating Viewer Auto-Open",
"msg.tip.body.mfvLivePreviewDefaults":
"Live Stream (green button in the viewer) and KSampler Preview can be activated by default via Settings \u2192 Majoor Assets Manager \u203a Viewer. When Live Stream is on, clicking a Load Image node or when a generation ends will automatically open the Floating Viewer and display the result. KSampler Preview streams the denoising steps live. Both toggles can be set as the default state so the viewer is always ready.",
"msg.whatsNew.title.version243": "New Version 2.4.3",
"msg.whatsNew.body.version243":
"Version 2.4.3 released: Improved assets metadata parsing, Grid Compare capability in floating viewer up to 4 Assets, ping pong loop in main Viewer player, job id and stack id in DB for better assets management, stack assets generated from same workflow job with same job ID, generated feed feature, lite version of grid in bottom tab. Code refactor for maintainability and various bug fixes. See CHANGELOG for details.",
@@ -1154,6 +1163,7 @@ const DICTIONARY = {
"tags.title": "Tags : {tags}",
"tags.label": "Tags",
"tags.addLabel": "Ajouter un tag",
"tags.remove": "Supprimer le tag",
"tags.suggestions": "Suggestions de tags",
"label.thisFile": "ce fichier",
"label.messages": "Messages",
@@ -1180,6 +1190,14 @@ const DICTIONARY = {
"msg.shortcuts.section.mfv": "Floating Viewer",
"msg.shortcuts.section.video": "Lecture video",
"msg.category.release": "Version",
"msg.whatsNew.title.version246": "Nouvelle Version 2.4.6",
"msg.whatsNew.body.version246":
"Version 2.4.6 publiee : divers correctifs de bugs et ameliorations de performances et fluidite. Support concatenate ameliore pour les nodes par defaut et custom (par Forsion07). Ajout des helpers pour Api Node et Ernie Image. Le Live Stream du Floating Viewer est desormais desactive par defaut. Voir CHANGELOG pour details.",
"label.changelog": "Changelog",
"label.settingsGuide": "Guide des param\u00e8tres",
"msg.tip.title.mfvLivePreviewDefaults": "Conseil — Ouverture automatique du Viewer",
"msg.tip.body.mfvLivePreviewDefaults":
"Le Live Stream (bouton vert dans le viewer) et la pr\u00e9visualisation KSampler peuvent \u00eatre activ\u00e9s par d\u00e9faut via Param\u00e8tres \u2192 Majoor Assets Manager \u203a Viewer. Lorsque le Live Stream est actif, cliquer sur un node Load Image ou la fin d'une g\u00e9n\u00e9ration ouvrira automatiquement le Floating Viewer et affichera le r\u00e9sultat. La pr\u00e9visualisation KSampler diffuse les \u00e9tapes de d\u00e9bruitage en direct. Les deux options peuvent \u00eatre d\u00e9finies comme \u00e9tat par d\u00e9faut pour que le viewer soit toujours pr\u00eat.",
"msg.whatsNew.title.version243": "Nouvelle Version 2.4.3",
"msg.whatsNew.body.version243":
"Version 2.4.3 publiee : analyse des metadonnees des assets amelioree, capacite Grid Compare dans le floating viewer jusqu'a 4 Assets, boucle ping pong dans le Viewer principal, job id et stack id dans la BDD pour une meilleure gestion des assets, empilement des assets generes depuis le meme workflow avec le meme job ID, fonctionnalite de feed genere, version legere de la grille dans l'onglet bottom. Refactorisation du code pour la maintenabilite et divers correctifs de bugs. Voir CHANGELOG pour details.",
+10
View File
@@ -59,6 +59,7 @@ import {
mountGlobalRuntime,
registerAssetsSidebar,
registerNativeCommands,
startEarlyFetch,
teardownTopBarMfvButton,
teardownGeneratedFeed,
teardownAssetsSidebar,
@@ -363,6 +364,15 @@ app.registerExtension({
void checkMajoorVersion();
}, 5000);
// 8b. Proactive early fetch — start the first assets page in the background
// so the data is ready (or already in-flight) when the user opens the
// sidebar. A 4-second delay lets ComfyUI finish its own startup work
// and the backend DB warm-up to settle before we hit /mjr/am/list.
// startEarlyFetch() is a no-op when ComfyUI is executing a prompt.
setTimeout(() => {
startEarlyFetch();
}, 4000);
// 9. WebSocket / realtime listeners.
void setupApiListeners(runtimeApp, executionRuntime).catch((e) =>
reportError(e, "entry.api_setup"),
@@ -295,19 +295,14 @@ export function createGridController({
};
const runReloadOnce = async (reloadOptions = {}) => {
// Expose the current query on the container so external listeners (ComfyUI executed events)
// can decide whether to do incremental upserts or avoid disrupting an active search.
try {
gridContainer.dataset.mjrQuery = String(getQuery?.() ?? "*") || "*";
} catch (e) {
console.debug?.(e);
}
const prevViewScope = String(gridContainer.dataset.mjrViewScope || "")
.trim()
.toLowerCase();
const nextViewScope = String(read("viewScope", state?.viewScope || "") || "")
.trim()
.toLowerCase();
// Write scope and filter state to the container dataset BEFORE reading
// the query so that fetchPage sees the finalized scope context.
gridContainer.dataset.mjrScope = read("scope", "output");
gridContainer.dataset.mjrViewScope = nextViewScope;
gridContainer.dataset.mjrCustomRootId = read("customRootId", "") || "";
@@ -328,6 +323,15 @@ export function createGridController({
gridContainer.dataset.mjrFilterDateExact = read("dateExactFilter", "") || "";
gridContainer.dataset.mjrSort = read("sort", "mtime_desc") || "mtime_desc";
gridContainer.dataset.mjrCollectionId = read("collectionId", "") || "";
gridContainer.dataset.mjrSemanticMode = _isSemanticMode() ? "1" : "0";
// Expose the current query on the container so external listeners (ComfyUI executed events)
// can decide whether to do incremental upserts or avoid disrupting an active search.
// Written after scope so that fetchPage sees the finalized context.
try {
gridContainer.dataset.mjrQuery = String(getQuery?.() ?? "*") || "*";
} catch (e) {
console.debug?.(e);
}
gridContainer.dataset.mjrGroupStacks =
APP_CONFIG.EXECUTION_GROUPING_ENABLED &&
(read("scope", "output") === "output" || read("scope", "output") === "all")
@@ -82,6 +82,9 @@ export function createScopeController({
const clearSimilarScope = async ({ reload = true } = {}) => {
const hadSimilar = hasSimilarResults();
resetSimilarScope();
// Clear stale search query when exiting similar view to avoid
// applying it to the scope the user returns to.
writeState("searchQuery", "");
setActiveTabStyles();
try {
onChanged?.();
@@ -124,9 +127,11 @@ export function createScopeController({
return;
}
// Switching scope exits any active collection view.
// Switching scope exits any active collection view and clears
// the search query so the new scope starts with a clean browse.
writeState("collectionId", "");
writeState("collectionName", "");
writeState("searchQuery", "");
resetSimilarScope();
const nextScope =
@@ -171,6 +171,36 @@ const BUILTIN_PANEL_MESSAGES = Object.freeze([
body: "A new Viewer button now appears in the ComfyUI top action bar. Click it to toggle the Majoor Floating Viewer on or off without opening the sidebar. The button highlights when the viewer is active and updates in real time.",
bodyKey: "msg.whatsNew.body.topbarMfvButton",
},
{
id: "whats-new-2026-04-20-version-2-4-6",
title: "New Version 2.4.6",
titleKey: "msg.whatsNew.title.version246",
emoji: "🚀",
category: "Release",
categoryKey: "msg.category.release",
level: "success",
createdAt: Date.parse("2026-04-20T09:00:00Z"),
body: "Version 2.4.6 released: Various bug fixes and performance & fluidity improvements. Improved concatenate support for default and custom nodes (by Forsion07). Added support helpers for Api Node and Ernie Image. Live Stream in Floating Viewer is now disabled by default. See CHANGELOG for details.",
bodyKey: "msg.whatsNew.body.version246",
actionLabel: "Changelog",
actionLabelKey: "label.changelog",
actionUrl: "CHANGELOG.md",
},
{
id: "tip-2026-04-20-mfv-live-stream-preview-defaults",
title: "Tip — Floating Viewer Auto-Open",
titleKey: "msg.tip.title.mfvLivePreviewDefaults",
emoji: "💡",
category: "Information",
categoryKey: "msg.category.information",
level: "info",
createdAt: Date.parse("2026-04-20T10:00:00Z"),
body: "Live Stream (green button in the viewer) and KSampler Preview can be activated by default via Settings → Majoor Assets Manager › Viewer. When Live Stream is on, clicking a Load Image node or when a generation ends will automatically open the Floating Viewer and display the result. KSampler Preview streams the denoising steps live. Both toggles can be set as the default state so the viewer is always ready.",
bodyKey: "msg.tip.body.mfvLivePreviewDefaults",
actionLabel: "Settings Guide",
actionLabelKey: "label.settingsGuide",
actionUrl: "docs/SETTINGS_CONFIGURATION.md",
},
]);
const BUILTIN_MESSAGE_IDS = new Set(
BUILTIN_PANEL_MESSAGES.map((msg) => String(msg?.id || "").trim()).filter(Boolean),
+11 -3
View File
@@ -800,18 +800,26 @@ async function _mountPanelRuntimeImpl(container, { useComfyThemeUI = true, exter
signal: panelLifecycleAC?.signal,
});
let raf = null;
let debounceTimer = null;
const schedule = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => { raf = null; notifyContextChanged(); });
// Debounce MutationObserver callbacks during pagination bursts
// (many cards inserted rapidly). Coalesce to a single update.
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => { raf = null; notifyContextChanged(); });
}, 250);
};
const mo = new MutationObserver(() => schedule());
mo.observe(gridContainer, { childList: true, subtree: true });
mo.observe(gridContainer, { childList: true });
gridContainer._mjrSummaryBarObserver = mo;
registerSummaryDispose(() => {
try { gridContainer.removeEventListener("mjr:grid-stats", onStats); }
catch (e) { console.debug?.(e); }
try { window.removeEventListener?.("mjr-settings-changed", onStats); }
catch (e) { console.debug?.(e); }
try { if (debounceTimer) clearTimeout(debounceTimer); } catch (e) { console.debug?.(e); }
try { mo.disconnect(); } catch (e) { console.debug?.(e); }
});
}
+34 -2
View File
@@ -45,8 +45,22 @@ const FEED_MOUNT_KEY = "_mjrFeedVueApp";
// This prefetched data is consumed by useGridLoader when it initializes.
let _earlyFetchPromise = null;
let _earlyFetchKey = null;
const EARLY_FETCH_TTL_MS = 5000; // Prefetch valid for 5 seconds
// 15 seconds — wide enough to cover slow ComfyUI startups and delayed sidebar
// opens while still invalidating stale data before it becomes misleading.
const EARLY_FETCH_TTL_MS = 15000;
let _earlyFetchTimestamp = 0;
let _earlyFetchAC = null;
// Abort the in-flight early fetch on page unload so it doesn't appear as an
// epoch-1 cancelled request in the next session's network log.
try {
window.addEventListener("pagehide", () => {
try { _earlyFetchAC?.abort?.(); } catch (e) { /* ignore */ }
_earlyFetchAC = null;
_earlyFetchPromise = null;
_earlyFetchKey = null;
}, { once: true });
} catch (e) { /* ignore */ }
function isExecutionBusy() {
try {
@@ -72,21 +86,38 @@ function startEarlyFetch() {
_earlyFetchTimestamp = now;
try {
// Create a dedicated AbortController so we can cancel this request
// cleanly on page unload (pagehide) instead of letting the browser
// kill it mid-flight and produce epoch-1 entries in the next session.
try { _earlyFetchAC?.abort?.(); } catch (e) { /* ignore */ }
_earlyFetchAC = typeof AbortController !== "undefined" ? new AbortController() : null;
const url = buildListURL({
query: "*",
limit: APP_CONFIG.DEFAULT_PAGE_SIZE || 200,
offset: 0,
scope: "output",
sort: "mtime_desc",
// Include total so useGridLoader can set state.total correctly.
// Without it the fallback was earlyAssets.length (page size),
// which caused state.done=true after the first page and blocked
// all subsequent pagination / infinite scroll.
includeTotal: true,
});
_earlyFetchPromise = get(url).catch(() => null);
_earlyFetchPromise = get(url, _earlyFetchAC ? { signal: _earlyFetchAC.signal } : {}).catch(() => null);
} catch {
_earlyFetchPromise = null;
_earlyFetchAC = null;
}
return _earlyFetchPromise;
}
/**
* Start the early fetch from an external caller (e.g. entry.js setup).
* Safe to call multiple times — returns existing promise if still valid.
*/
export { startEarlyFetch };
/**
* Consume the early fetch result if available and matching.
* Called by useGridLoader on first load.
@@ -105,6 +136,7 @@ export function consumeEarlyFetch(key = "output:*:mtime_desc") {
// Clear after consumption to avoid stale reuse
_earlyFetchPromise = null;
_earlyFetchKey = null;
_earlyFetchAC = null;
return promise;
}
@@ -400,7 +400,11 @@ export async function registerRealtimeListeners({
if (executionRuntime.isRenderableLiveAsset(detail)) {
pushGeneratedAsset(detail);
}
upsertLiveAssetIntoGrid(grid, detail);
// Use immediate flush for updates to assets already visible in
// the grid (enrichment / vector indexing results). This avoids
// a 200 ms debounce window during which the card shows stale
// metadata or temporarily disappears then reappears.
upsertLiveAssetIntoGrid(grid, detail, { immediate: true });
}
} catch (error) {
reportError(error, "entry.asset_updated");
@@ -20,6 +20,7 @@ const EXECUTION_START_TTL_MS = 10 * 60 * 1000;
const EXECUTION_STARTS_MAX = 500;
const ORPHAN_GENERATION_RETRY_MAX_ATTEMPTS = 6;
const ORPHAN_GENERATION_ABSOLUTE_TIMEOUT_MS = 30_000;
const POST_EXECUTION_BUFFER_FLUSH_FAST_MS = 500;
export function createExecutionRuntimeController({
post,
@@ -202,7 +203,7 @@ export function createExecutionRuntimeController({
}
function schedulePostExecutionWork(work, delayMs = executionIdleGraceMs) {
const waitMs = Math.max(0, Number(delayMs) || 0, executionIdleGraceMs);
const waitMs = Math.max(0, Number(delayMs) || 0);
clearPostExecutionWorkTimer();
postExecutionWorkTimer = setTimeout(() => {
postExecutionWorkTimer = null;
@@ -420,6 +421,18 @@ export function createExecutionRuntimeController({
});
}
function hasBufferedGenerationFiles(jobId) {
const promptId = String(jobId || "").trim();
if (!promptId) return false;
try {
const bucket = executionAssetBuffer?._jobs?.get?.(promptId);
return !!bucket && typeof bucket.size === "number" && bucket.size > 0;
} catch (e) {
console.debug?.(e);
return false;
}
}
function prepareLiveAssetEvent(detail) {
if (!isExecutionGroupingEnabled()) {
return { detail, jobId: "", defer: false };
@@ -485,7 +498,18 @@ export function createExecutionRuntimeController({
} catch (e) {
console.debug?.(e);
}
bufferGenerationIndex(files, { prompt_id: promptId });
const immediateFiles = files.length > 0 ? [files[0]] : [];
const bufferedFiles = files.length > 1 ? files.slice(1) : [];
// Prioritize the first renderable asset from each executed payload so the
// placeholder is replaced by a real card almost immediately, while the
// remaining outputs continue through the grouped post-execution flow.
if (immediateFiles.length > 0) {
scheduleGenerationIndex(immediateFiles, 1, { prompt_id: promptId });
}
if (bufferedFiles.length > 0) {
bufferGenerationIndex(bufferedFiles, { prompt_id: promptId });
}
}
function handleExecutionStart(event, { setCurrentJobId }) {
@@ -545,12 +569,15 @@ export function createExecutionRuntimeController({
if (policy.usePostExecutionScan) {
schedulePostExecutionScan(executionIdleGraceMs);
} else {
const flushDelayMs = hasBufferedGenerationFiles(promptId)
? Math.min(executionIdleGraceMs, POST_EXECUTION_BUFFER_FLUSH_FAST_MS)
: executionIdleGraceMs;
schedulePostExecutionWork(() => {
if (promptId) {
flushOrphanGenerationEntries(promptId);
}
flushBufferedGenerationIndex(promptId);
}, executionIdleGraceMs);
}, flushDelayMs);
}
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ export function buildFloatingViewerHeader(viewer) {
const title = document.createElement("span");
title.className = "mjr-mfv-header-title";
title.id = viewer._titleId;
title.textContent = "Majoor Viewer Lite";
title.textContent = "〽️ Majoor Viewer Lite";
const closeBtn = document.createElement("button");
viewer._closeBtn = closeBtn;
@@ -150,7 +150,9 @@ export function createRunButton() {
async function stopCurrentGeneration() {
const app = getComfyApp();
const api = getComfyApi(app);
// Prefer live app.api for the same auth-context reasons as queueCurrentPrompt.
const liveApi = (app?.api && typeof app.api.interrupt === "function") ? app.api : null;
const api = liveApi ?? getComfyApi(app);
if (api && typeof api.interrupt === "function") {
await api.interrupt();
@@ -163,7 +165,7 @@ export function createRunButton() {
return { tracked: true };
}
const resp = await fetch("/interrupt", { method: "POST" });
const resp = await fetch("/interrupt", { method: "POST", credentials: "include" });
if (!resp.ok) throw new Error(`POST /interrupt failed (${resp.status})`);
return { tracked: false };
}
@@ -295,7 +297,15 @@ function resolveClientId(api, app) {
/**
* Queue the current workflow for execution.
* Tries, in order: api.queuePrompt -> POST /prompt.
*
* Tries, in order:
* 1. app.api.queuePrompt — live reference on the app object (always has the
* current auth session, same source as the native ComfyUI Queue button).
* 2. api.queuePrompt — broader detection via getComfyApi() (window.api, …).
* 3. api.fetchApi — direct HTTP via ComfyUI's authenticated fetch helper.
* 4. app.queuePrompt(0) — native ComfyUI app-level path; handles auth tokens
* and CSRF checks natively; no preview-method enrichment on this path.
* 5. fetch (last resort) — raw fetch with credentials:'include' for cookie auth.
*/
async function queueCurrentPrompt() {
const app = getComfyApp();
@@ -306,7 +316,12 @@ async function queueCurrentPrompt() {
: null;
if (!promptData?.output) throw new Error("graphToPrompt returned empty output");
const api = getComfyApi(app);
// Prefer the live app.api reference — this matches the native Queue button's
// auth context and avoids stale cached references when ComfyUI refreshes its
// auth state (e.g. after user logs in or session is renewed).
const liveApi = (app?.api && typeof app.api.queuePrompt === "function") ? app.api : null;
const api = liveApi ?? getComfyApi(app);
if (api && typeof api.queuePrompt === "function") {
await api.queuePrompt(0, enrichPromptDataForMfv(promptData));
return { tracked: true };
@@ -326,12 +341,20 @@ async function queueCurrentPrompt() {
return { tracked: true };
}
// Native app-level path: uses ComfyUI's own auth handling (same as the
// native Queue button). No preview-method enrichment on this fallback.
if (typeof app.queuePrompt === "function") {
await app.queuePrompt(0);
return { tracked: true };
}
const resp = await fetch("/prompt", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildPromptRequestBody(promptData, {
clientId: resolveClientId(api, app),
clientId: resolveClientId(null, app),
}),
),
});
+4 -1
View File
@@ -100,7 +100,10 @@ describe("assetsGridHostState", () => {
expect(gridWrapper.scrollTop).toBe(33);
expect(gridContainer._mjrSetSelection).toHaveBeenCalledWith(["asset-7"], "asset-7");
expect(gridContainer._mjrScrollToAssetId).not.toHaveBeenCalled();
// scrollToAssetId is now always called so the selected card is scrolled into
// view even when a saved scrollTop was restored (align:"auto" keeps the view
// stable when the card is already visible).
expect(gridContainer._mjrScrollToAssetId).toHaveBeenCalledWith("asset-7");
expect(onRestoreSidebar).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,90 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { createExecutionRuntimeController } from "../features/stacks/executionRuntimeController.js";
describe("createExecutionRuntimeController", () => {
beforeEach(() => {
vi.useFakeTimers();
if (typeof globalThis.CustomEvent === "undefined") {
globalThis.CustomEvent = class CustomEvent {
constructor(type, init = {}) {
this.type = type;
this.detail = init.detail;
}
};
}
if (typeof globalThis.window === "undefined") {
globalThis.window = {
dispatchEvent: () => true,
};
}
});
afterEach(() => {
vi.useRealTimers();
});
it("indexes the first executed output immediately and buffers the rest until execution end", async () => {
const post = vi.fn(async () => ({ ok: true, data: {} }));
const controller = createExecutionRuntimeController({
post,
ENDPOINTS: {
INDEX_FILES: "/index-files",
STACKS_AUTO_STACK: "/stacks/auto-stack",
SCAN: "/scan",
},
reportError: () => {},
extractOutputFiles: (output) => output,
ensureExecutionRuntime: () => ({ active_prompt_id: "job-1" }),
emitRuntimeStatus: () => {},
refreshGeneratedFeedHosts: () => {},
getActiveGridContainer: () => null,
});
controller.handleExecutionStart({ detail: { prompt_id: "job-1", timestamp: Date.now() } }, {
setCurrentJobId: () => {},
});
controller.handleExecutedEvent(
{
detail: {
prompt_id: "job-1",
output: [
{ filename: "first.png", subfolder: "out", type: "output" },
{ filename: "second.png", subfolder: "out", type: "output" },
],
},
},
{ appRef: {} },
);
expect(post).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenNthCalledWith(
1,
"/index-files",
expect.objectContaining({
origin: "generation",
prompt_id: "job-1",
files: [expect.objectContaining({ filename: "first.png" })],
}),
);
controller.handleExecutionEnd(
{ type: "execution_success", detail: { prompt_id: "job-1" } },
{ setCurrentJobId: () => {} },
);
await vi.advanceTimersByTimeAsync(500);
expect(post).toHaveBeenCalledTimes(2);
expect(post).toHaveBeenNthCalledWith(
2,
"/index-files",
expect.objectContaining({
origin: "generation",
prompt_id: "job-1",
files: [expect.objectContaining({ filename: "second.png" })],
}),
);
});
});
+15 -9
View File
@@ -48,7 +48,7 @@ describe("InfiniteScroll upsert", () => {
).toBe(16);
});
it("only skips total counts after the first page for output scope", async () => {
it("skips total counts for default output browse and later output pages only", async () => {
const buildListURL = vi.fn(() => "/mjr/am/list");
const deps = {
sanitizeQuery: (value) => value,
@@ -61,19 +61,25 @@ describe("InfiniteScroll upsert", () => {
};
const cases = [
["output", 0, true],
["output", 100, false],
["all", 100, true],
["input", 100, true],
["custom", 100, true],
[{ dataset: { mjrScope: "output", mjrSort: "mtime_desc" } }, "*", 0, false],
[{ dataset: { mjrScope: "output", mjrSort: "mtime_desc" } }, "*", 100, false],
[{ dataset: { mjrScope: "output", mjrSort: "mtime_desc" } }, "portrait", 0, true],
[{ dataset: { mjrScope: "output", mjrSort: "mtime_desc", mjrFilterMinRating: "3" } }, "*", 0, true],
[{ dataset: { mjrScope: "all", mjrSort: "mtime_desc" } }, "*", 100, true],
[{ dataset: { mjrScope: "input", mjrSort: "mtime_desc" } }, "*", 100, true],
[{ dataset: { mjrScope: "custom", mjrSort: "mtime_desc" } }, "*", 100, true],
];
for (const [scope, offset, includeTotal] of cases) {
await fetchPage({ dataset: { mjrScope: scope } }, "*", 100, offset, deps, {
for (const [gridContainer, query, offset, includeTotal] of cases) {
await fetchPage(gridContainer, query, 100, offset, deps, {
requestId: 1,
});
const lastCall = buildListURL.mock.calls[buildListURL.mock.calls.length - 1]?.[0];
expect(lastCall).toMatchObject({ scope, offset, includeTotal });
expect(lastCall).toMatchObject({
scope: gridContainer.dataset.mjrScope,
offset,
includeTotal,
});
}
});
+2 -1
View File
@@ -123,6 +123,7 @@ let lastSaved = [...initialTags];
const retryDelay = (attemptIndex) => Math.min(100 * 2 ** Math.max(0, attemptIndex - 1), 2000);
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const handleInputBlur = () => setTimeout(() => (showDropdown.value = false), 200);
const filteredSuggestions = computed(() => {
const query = inputValue.value.toLowerCase().trim();
@@ -420,7 +421,7 @@ watch(
@focus="showDropdown = inputValue.length > 0"
@input="showDropdown = inputValue.length > 0"
@keydown="handleInputKeydown"
@blur="setTimeout(() => (showDropdown = false), 200)"
@blur="handleInputBlur"
/>
<div
@@ -767,7 +767,11 @@ watch(
watch(
() => state.assets,
async () => {
async (newAssets) => {
if (!Array.isArray(newAssets) || newAssets.length === 0) {
const gc = gridContainerRef.value;
if (gc?._mjrStackMembersCache) gc._mjrStackMembersCache.clear();
}
await nextTick();
syncAllRenderedDuplicateCards();
},
@@ -1025,8 +1029,29 @@ function handleCardDblclick(asset) {
let scrollCleanup = null;
function _forceVirtualizerMeasure() {
updateHostWidth();
try {
rowVirtualizer.value?.measure?.();
} catch (e) {
console.debug?.(e);
}
}
function handleKeepAliveAttached() {
if (!isGridVisible()) return;
_forceVirtualizerMeasure();
if (!isGridVisible()) {
// Le layout du nouveau container n'est pas encore calculé au moment
// où keepalive-attached est dispatché. On reporte sur le prochain
// cycle de rendu pour que le navigateur ait fini la mise en page.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
_forceVirtualizerMeasure();
void maybeFillViewport();
});
});
return;
}
void maybeFillViewport();
}
@@ -1037,7 +1062,10 @@ function bindInfiniteScroll(element) {
const remaining =
Number(element.scrollHeight || 0) -
(Number(element.scrollTop || 0) + Number(element.clientHeight || 0));
if (remaining <= Math.max(120, estimateRowHeight.value * 2)) {
// Start loading the next page when the user is 4 card-rows from the bottom
// (was 2). This hides API latency and prevents the visible "waiting at
// the bottom" freeze.
if (remaining <= Math.max(600, estimateRowHeight.value * 4)) {
void loader.loadNextPage();
}
};
+25 -15
View File
@@ -234,22 +234,32 @@ export async function restoreGridUiState({
console.debug?.(e);
}
if (activeId && scrollTop === 0) {
try {
if (typeof gridContainer?._mjrScrollToAssetId === "function") {
gridContainer._mjrScrollToAssetId(activeId);
} else {
const activeCard = gridContainer.querySelector?.(
`.mjr-asset-card[data-mjr-asset-id="${safeEscapeAttr(activeId)}"]`,
);
activeCard?.scrollIntoView?.({
block: "nearest",
behavior: "instant",
});
// Always scroll to the active card so the selection is visible regardless of
// the saved scroll position. Using align:"auto" (default) means the virtualizer
// will not move if the card is already in view, so this is side-effect-free
// when the saved scrollTop already shows the right area.
if (activeId) {
const scrollToSelection = () => {
try {
if (typeof gridContainer?._mjrScrollToAssetId === "function") {
gridContainer._mjrScrollToAssetId(activeId);
} else {
const activeCard = gridContainer.querySelector?.(
`.mjr-asset-card[data-mjr-asset-id="${safeEscapeAttr(activeId)}"]`,
);
activeCard?.scrollIntoView?.({
block: "nearest",
behavior: "instant",
});
}
} catch (e) {
console.debug?.(e);
}
} catch (e) {
console.debug?.(e);
}
};
scrollToSelection();
// Second pass: handles fresh loads where Vue needs extra time to render items
// into the virtualizer before scrollToIndex can locate the correct row.
setTimeout(scrollToSelection, 160);
}
}
+8 -4
View File
@@ -122,8 +122,12 @@ export function createAssetsQueryController({
try {
const currentScrollTop = Number(readScrollTop() || 0) || 0;
const anchorScrollTop = Number(anchor?.scrollTop || 0) || 0;
const userScrolledDuringReload = Math.abs(currentScrollTop - anchorScrollTop) > 8;
if (userScrolledDuringReload) {
// Only skip restore if user actively scrolled DOWN past the original
// position during the reload. A drop in scrollTop (currentScrollTop <
// anchorScrollTop) means the reload itself reset the scroll — we must
// restore in that case to avoid losing the user's position.
const userScrolledPastAnchor = currentScrollTop > anchorScrollTop + 8;
if (userScrolledPastAnchor) {
continue;
}
await restoreAnchor(gridContainer, anchor);
@@ -291,7 +295,7 @@ export function createAssetsQueryController({
const needsFallbackReload =
hasNewIndexEnd && !hasNewScan && !hasNewTotal && !upsertHandledRecently;
if (totalExplainedByRecentUpserts && !hasNewScan) {
if (totalExplainedByRecentUpserts) {
lastKnownScan = counters.last_scan_end;
lastKnownIndexEnd = counters.last_index_end;
try {
@@ -306,7 +310,7 @@ export function createAssetsQueryController({
lastKnownIndexEnd = counters.last_index_end;
return;
}
if (upsertHandledRecently && !hasNewScan) return;
if (upsertHandledRecently) return;
if (!hasNewScan && !hasNewTotal && !needsFallbackReload) return;
if (!syncVisibilityState()) return;
+114 -21
View File
@@ -98,6 +98,32 @@ function normalizeSnapshotPart(value, fallback = "") {
}
}
function getStableRealtimeAssetKey(asset) {
if (!asset || typeof asset !== "object") return "";
if (asset.id != null && String(asset.id).trim()) {
return `id:${String(asset.id).trim()}`;
}
const type = String(asset?.type || asset?.source || "output")
.trim()
.toLowerCase();
const rootId = String(pickRootId(asset) || "")
.trim()
.toLowerCase();
const subfolder = String(asset?.subfolder || "")
.trim()
.toLowerCase();
const filename = String(asset?.filename || "")
.trim()
.toLowerCase();
if (filename) return `${type}|${rootId}|${subfolder}|${filename}`;
const filepath = String(
asset?.filepath || asset?.path || asset?.fullpath || asset?.full_path || "",
)
.trim()
.toLowerCase();
return filepath ? `${type}|${rootId}|path|${filepath}` : "";
}
export function buildGridSnapshotKey(parts = {}) {
return JSON.stringify({
scope: normalizeSnapshotPart(parts.scope || "output", "output"),
@@ -120,6 +146,7 @@ export function buildGridSnapshotKey(parts = {}) {
dateRange: normalizeSnapshotPart(parts.dateRange || ""),
dateExact: normalizeSnapshotPart(parts.dateExact || ""),
sort: normalizeSnapshotPart(parts.sort || "mtime_desc", "mtime_desc"),
semanticMode: normalizeSnapshotPart(parts.semanticMode ? "1" : ""),
});
}
@@ -355,6 +382,7 @@ export function useGridLoader({
} = {}) {
let deferVisualResetUntilNextPage = false;
let deferredExecutionReload = null;
let prefetchTimer = null;
function getGridContainer() {
return resolveElement(gridContainerRef);
@@ -383,6 +411,7 @@ export function useGridLoader({
dateRange: gridContainer?.dataset?.mjrFilterDateRange || "",
dateExact: gridContainer?.dataset?.mjrFilterDateExact || "",
sort: gridContainer?.dataset?.mjrSort || "mtime_desc",
semanticMode: gridContainer?.dataset?.mjrSemanticMode === "1",
};
}
@@ -432,6 +461,34 @@ export function useGridLoader({
deferredExecutionReload = null;
}
function clearPrefetchTimer() {
try {
if (prefetchTimer) {
clearTimeout(prefetchTimer);
}
} catch (e) {
console.debug?.(e);
}
prefetchTimer = null;
}
function scheduleNextPagePrefetch(requestId, delayMs = APP_CONFIG.PREFETCH_NEXT_PAGE_DELAY_MS) {
if (!APP_CONFIG.PREFETCH_NEXT_PAGE) {
return;
}
clearPrefetchTimer();
const resolvedDelayMs = Math.max(0, Number(delayMs) || 0);
prefetchTimer = setTimeout(() => {
prefetchTimer = null;
if (state.requestId !== requestId || state.done || state.loading) {
return;
}
loadNextPage().catch((error) => {
console.debug?.("[AssetsManager][GridLoader] Delayed prefetch failed", error);
});
}, resolvedDelayMs);
}
function scheduleDeferredExecutionReload(query, options = {}) {
clearDeferredExecutionReload();
const retry = () => {
@@ -447,9 +504,15 @@ export function useGridLoader({
return;
}
clearDeferredExecutionReload();
// Re-read the current query from the grid container at retry time
// instead of using the stale captured value. The user may have
// changed the search query while execution was in progress.
const gridContainer = getGridContainer();
const freshQuery =
String(gridContainer?.dataset?.mjrQuery || "").trim() || query;
Promise.resolve()
.then(() =>
loadAssets(query, {
loadAssets(freshQuery, {
...(options || {}),
reset: true,
preserveVisibleUntilReady: true,
@@ -592,14 +655,27 @@ export function useGridLoader({
if (earlyFetchPromise) {
try {
const earlyResult = await earlyFetchPromise;
// Discard early fetch if the request has been superseded
// (e.g. scope switch while the promise was pending).
if (Number(state.requestId) !== Number(requestId)) {
return { ok: false, stale: true, error: "Stale early fetch" };
}
if (signal?.aborted) {
return { ok: false, aborted: true, error: "Aborted" };
}
// API returns { ok, data: { assets: [...], total, ... } }
const earlyAssets = earlyResult?.data?.assets;
if (earlyResult?.ok && Array.isArray(earlyAssets)) {
mjrDbg("[Grid] Using early-fetched data:", earlyAssets.length, "assets");
// Use null when the backend did not return a total (includeTotal=false).
// Falling back to earlyAssets.length would incorrectly set state.total
// to the page size (e.g. 80), causing state.done=true after the first
// page and blocking all subsequent pagination / infinite scroll.
const rawTotal = earlyResult.data?.total ?? earlyResult.meta?.total ?? null;
return {
ok: true,
assets: earlyAssets,
total: Number(earlyResult.data?.total ?? earlyResult.meta?.total ?? earlyAssets.length) || 0,
total: rawTotal != null ? Number(rawTotal) || 0 : null,
count: earlyAssets.length,
limit: limit,
offset: 0,
@@ -827,6 +903,7 @@ export function useGridLoader({
state.abortController = null;
}
state.requestId = (Number(state.requestId) || 0) + 1;
clearPrefetchTimer();
clearPendingUpserts();
clearStatusMessage();
if (deferVisualResetUntilNextPage) {
@@ -864,13 +941,11 @@ export function useGridLoader({
state.done = false;
state.loading = false;
clearLoadingMessage();
queueMicrotask(() => {
loadNextPage().then((bgResult) => {
if (bgResult?.ok && !bgResult?.aborted) {
finalizeLoad({ title: safeQuery });
}
}).catch(() => {});
});
const bgRequestId = state.requestId;
scheduleNextPagePrefetch(
bgRequestId,
Math.min(APP_CONFIG.PREFETCH_NEXT_PAGE_DELAY_MS ?? 700, 250),
);
return {
ok: true,
count: Number(state.offset || 0) || 0,
@@ -886,7 +961,7 @@ export function useGridLoader({
const result = await loadNextPage();
// Prefetch next page immediately for faster scrolling (non-blocking)
// Prefetch next page after the first render settles.
if (
APP_CONFIG.PREFETCH_NEXT_PAGE &&
result?.ok &&
@@ -894,12 +969,7 @@ export function useGridLoader({
!state.done &&
!result?.aborted
) {
// Schedule prefetch after current microtask to not block initial render
queueMicrotask(() => {
if (!state.done && !state.loading) {
loadNextPage().catch(() => {/* ignore prefetch errors */});
}
});
scheduleNextPagePrefetch(state.requestId);
}
if (reset) {
@@ -1031,6 +1101,7 @@ export function useGridLoader({
}
state.requestId = (Number(state.requestId) || 0) + 1;
state.loading = false;
clearPrefetchTimer();
clearPendingUpserts();
clearLoadingMessage();
clearStatusMessage();
@@ -1189,6 +1260,7 @@ export function useGridLoader({
state.abortController = null;
}
state.requestId = (Number(state.requestId) || 0) + 1;
clearPrefetchTimer();
clearPendingUpserts();
resetAssets({
query: snapshot.query || options.title || "Cached",
@@ -1218,7 +1290,7 @@ export function useGridLoader({
upsertState: UPSERT_BATCH_STATE,
maxBatchSize: UPSERT_BATCH_MAX_SIZE,
debounceMs: UPSERT_BATCH_DEBOUNCE_MS,
assetKey: (asset) => assetKey(asset, gridContainer),
assetKey: (asset) => getStableRealtimeAssetKey(asset) || assetKey(asset, gridContainer),
loadMajoorSettings,
};
}
@@ -1230,22 +1302,35 @@ export function useGridLoader({
}
/**
* Upsert an asset and immediately flush the batch to the virtual grid,
* bypassing the debounce timer. Used for live generation events so the
* card appears as soon as the WS event arrives rather than ~200 ms later.
* Upsert an asset and flush within the current microtask boundary.
*
* Multiple calls in the same event-loop tick are coalesced into a single
* flush via queueMicrotask. This avoids N separate flush+setItems calls
* when N WebSocket events arrive nearly simultaneously (e.g. 5 images
* generated in a batch), while still being much faster than the 200 ms
* debounce path.
*/
let _immediateFlushScheduled = false;
function upsertAssetNow(asset) {
const gridContainer = getGridContainer();
if (!gridContainer || !asset || !asset.id) return false;
const deps = _buildUpsertDeps(gridContainer);
const ok = queueUpsertAsset(gridContainer, asset, deps);
if (ok) flushUpsertBatch(gridContainer, deps);
if (ok && !_immediateFlushScheduled) {
_immediateFlushScheduled = true;
queueMicrotask(() => {
_immediateFlushScheduled = false;
const gc = getGridContainer();
if (gc) flushUpsertBatch(gc, _buildUpsertDeps(gc));
});
}
return ok;
}
function dispose() {
rememberSnapshot(state.query || "Cached");
clearDeferredExecutionReload();
clearPrefetchTimer();
try {
state.abortController?.abort?.();
} catch (e) {
@@ -1266,6 +1351,14 @@ export function useGridLoader({
}
}
// Abort any in-flight requests before the page unloads so they don't
// appear as epoch-1 cancelled requests in the next session's network log.
try {
window.addEventListener("pagehide", dispose, { once: true });
} catch (e) {
console.debug?.(e);
}
return {
loadAssets,
loadAssetsFromList,
+4 -1
View File
@@ -57,8 +57,11 @@ export function bindGridSelectionState({
console.debug?.(e);
}
if (!activeId || Number(scrollTop || 0) > 0) return;
if (!activeId) return;
// Always scroll to the active card. align:"auto" (default) ensures the
// virtualizer won't move if the card is already in the viewport, so
// this is side-effect-free when the saved scroll position is still valid.
try {
if (typeof gridContainer?._mjrScrollToAssetId === "function") {
gridContainer._mjrScrollToAssetId(activeId);
+58 -8
View File
@@ -317,12 +317,15 @@ function _assetMatchesActiveFilters(gridContainer, asset) {
.trim()
.toLowerCase();
const assetDate = String(asset?.date_exact || asset?.date || "").trim();
const hasWorkflow = Boolean(asset?.has_workflow ?? asset?.hasWorkflow);
const hasWorkflow = asset?.has_workflow ?? asset?.hasWorkflow ?? null;
if (scope && scope !== "all" && assetType && assetType !== scope) return false;
if (subfolder && assetSubfolder !== subfolder) return false;
if (kind && assetKind && assetKind !== kind) return false;
if (workflowOnly && !hasWorkflow) return false;
// Treat null/undefined has_workflow as "pending" (enrichment in progress)
// so freshly generated assets are not rejected by the workflow-only filter.
// Only reject when has_workflow is explicitly false/0.
if (workflowOnly && hasWorkflow !== null && !hasWorkflow) return false;
if (minRating > 0 && (Number(asset?.rating || 0) || 0) < minRating) return false;
if (workflowType && assetWorkflowType !== workflowType) return false;
if (dateExact && assetDate && assetDate !== dateExact) return false;
@@ -379,9 +382,18 @@ export async function fetchPage(
: requestedQuery;
const safeQuery = deps.sanitizeQuery(normalizedRequestedQuery) || normalizedRequestedQuery;
try {
const includeTotal = !(
String(scope || "").toLowerCase() === "output" && Number(offset ?? 0) > 0
);
const isOutputScope = String(scope || "").toLowerCase() === "output";
const hasActiveFilters =
!!(subfolder || customRootId || kind || workflowOnly || minRating > 0 || minSizeMB > 0 ||
maxSizeMB > 0 || minWidth > 0 || minHeight > 0 || maxWidth > 0 || maxHeight > 0 ||
workflowType || dateRange || dateExact || groupStacks);
const isDefaultOutputBrowse =
isOutputScope &&
Number(offset ?? 0) === 0 &&
safeQuery === "*" &&
!hasActiveFilters &&
String(sortKey || "mtime_desc").toLowerCase() === "mtime_desc";
const includeTotal = !(isOutputScope && (Number(offset ?? 0) > 0 || isDefaultOutputBrowse));
const url = deps.buildListURL({
q: safeQuery,
limit,
@@ -406,7 +418,7 @@ export async function fetchPage(
includeTotal,
groupStacks,
});
const result = await deps.get(url, signal ? { signal } : undefined);
const result = await deps.get(url, { timeoutMs: 120_000, ...(signal ? { signal } : {}) });
try {
const state = deps.getGridState(gridContainer);
if (state && Number(state.requestId) !== Number(requestId)) {
@@ -642,6 +654,31 @@ function _findExistingAssetIndex(state, assetId, candidateAsset) {
return list.findIndex((asset) => _getAssetIdentityKey(asset) === identityKey);
}
function _shouldKeepExistingAssetOnFilterMismatch(incomingAsset, existingAsset) {
if (!existingAsset || typeof existingAsset !== "object") return false;
if (!incomingAsset || typeof incomingAsset !== "object") return false;
if (_isLivePlaceholderAsset(existingAsset)) return true;
const protectedFields = [
"filename",
"filepath",
"path",
"fullpath",
"full_path",
"subfolder",
"type",
"source",
"root_id",
"custom_root_id",
"kind",
"rating",
"workflow_type",
"workflowType",
"date_exact",
"date",
];
return !protectedFields.some((field) => Object.prototype.hasOwnProperty.call(incomingAsset, field));
}
function dedupeAssetsByKey(state, deps) {
const seenIds = new Set();
const seenKeys = new Set();
@@ -761,6 +798,15 @@ export function flushUpsertBatch(gridContainer, deps) {
const matchesFilters = _assetMatchesActiveFilters(gridContainer, mergedCandidate);
if (!matchesFilters) {
if (resolvedExistingIndex > -1) {
if (_shouldKeepExistingAssetOnFilterMismatch(asset, resolvedExistingAsset)) {
Object.assign(resolvedExistingAsset, asset);
if (!incomingIsPlaceholder) {
_clearLivePlaceholderState(resolvedExistingAsset);
}
state.assets[resolvedExistingIndex] = { ...resolvedExistingAsset };
modified = true;
continue;
}
const [removedAsset] = state.assets.splice(resolvedExistingIndex, 1);
unregisterHiddenSibling(state, removedAsset, state);
modified = true;
@@ -811,12 +857,16 @@ export function flushUpsertBatch(gridContainer, deps) {
}
} finally {
batchState.flushing = false;
// Items may have arrived during the flush — reschedule if needed (BUG-01).
// Items may have arrived during the flush — reschedule if needed (BUG-01).
// Use a short delay (1 frame) instead of the full debounce so rapid
// generation events chain quickly instead of waiting 200 ms between
// each visible card insertion.
if (batchState.pending.size > 0 && !batchState.timer) {
const followUpDelay = Math.min(16, deps.debounceMs);
batchState.timer = setTimeout(() => {
batchState.timer = null;
flushUpsertBatch(gridContainer, deps);
}, deps.debounceMs);
}, followUpDelay);
}
}
}
@@ -1 +1 @@
.mjr-rating-badge[data-v-821bf1b1]{position:absolute;top:6px;right:6px;background:#0000008c;border:1px solid rgba(255,255,255,.12);padding:2px 6px;border-radius:6px;font-size:13px;letter-spacing:1px;display:inline-flex;align-items:center;justify-content:center;pointer-events:none;z-index:10;text-shadow:0 2px 6px rgba(0,0,0,.6);box-shadow:0 6px 18px #00000040;animation:mjr-fade-in-821bf1b1 .2s ease-out}@keyframes mjr-fade-in-821bf1b1{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}.mjr-tags-badge[data-v-15044260]{position:absolute;bottom:6px;left:6px;padding:3px 6px;border-radius:4px;background:#000c;font-size:9px;max-width:calc(100% - 56px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;animation:mjr-fade-in-15044260 .2s ease-out}@keyframes mjr-fade-in-15044260{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.mjr-gentime-badge[data-v-60fe3677]{position:absolute;bottom:6px;right:6px;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:800;letter-spacing:.015em;line-height:1.1;background:#0000008c;border:1px solid rgba(255,255,255,.12);pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;text-shadow:0 1px 2px rgba(0,0,0,.9),0 0 10px color-mix(in srgb,currentColor 35%,transparent);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px)}.mjr-popover-row[data-v-d1ebebd4]{display:grid;grid-template-columns:110px 1fr;gap:8px;align-items:center;margin-bottom:8px}.mjr-popover-row--3col[data-v-d1ebebd4]{grid-template-columns:110px 1fr 1fr}.mjr-popover-label[data-v-d1ebebd4]{font-size:12px;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)));white-space:nowrap}.mjr-popover-toggle[data-v-d1ebebd4]{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--fg-color, #e6edf7)}.mjr-filter-group-toggle[data-v-d1ebebd4]{width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0;margin:0 0 10px;border:0;background:transparent;color:inherit;cursor:pointer;text-align:left}.mjr-filter-group-title[data-v-d1ebebd4]{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.mjr-filter-group-chevron[data-v-d1ebebd4]{font-size:12px;line-height:1;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)))}.mjr-filter-group-body[data-v-d1ebebd4]{padding-top:2px}.mjr-filter-group[data-v-d1ebebd4]:not(.is-open){padding-bottom:8px}.mjr-filter-group:not(.is-open) .mjr-filter-group-toggle[data-v-d1ebebd4]{margin-bottom:0}.mjr-rating-star[data-v-6d012325]{outline:none}.mjr-rating-star[data-v-6d012325]:hover:not(:disabled){transform:scale(1.2)}.mjr-rating-star[data-v-6d012325]:focus-visible{outline:2px solid var(--focus-outline-color, #4dabf7);outline-offset:2px;border-radius:2px}.mjr-grid-skeleton-layout[data-v-41c1fc8a]{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:12px}.mjr-grid-skeleton-card[data-v-41c1fc8a]{border-radius:12px;border:1px solid rgba(255,255,255,.22);background:#c0c6ce33;overflow:hidden;min-height:146px;box-shadow:0 6px 18px #0000003d}.mjr-grid-skeleton-thumb[data-v-41c1fc8a]{aspect-ratio:1 / 1;width:100%;background:linear-gradient(90deg,#9ca2ab85,#dce1e8b8 45%,#9ca2ab85);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-41c1fc8a 1.2s linear infinite}.mjr-grid-skeleton-line[data-v-41c1fc8a]{height:10px;margin:8px 10px;border-radius:8px;background:linear-gradient(90deg,#a4aab37a,#d6dbe2b3 45%,#a4aab37a);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-41c1fc8a 1.2s linear infinite}.mjr-grid-skeleton-line--title[data-v-41c1fc8a]{width:72%}.mjr-grid-skeleton-line--meta[data-v-41c1fc8a]{width:48%;margin-top:2px;margin-bottom:10px}@keyframes mjr-grid-skeleton-shimmer-41c1fc8a{0%{background-position:100% 0}to{background-position:-100% 0}}
.mjr-rating-badge[data-v-821bf1b1]{position:absolute;top:6px;right:6px;background:#0000008c;border:1px solid rgba(255,255,255,.12);padding:2px 6px;border-radius:6px;font-size:13px;letter-spacing:1px;display:inline-flex;align-items:center;justify-content:center;pointer-events:none;z-index:10;text-shadow:0 2px 6px rgba(0,0,0,.6);box-shadow:0 6px 18px #00000040;animation:mjr-fade-in-821bf1b1 .2s ease-out}@keyframes mjr-fade-in-821bf1b1{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}.mjr-tags-badge[data-v-15044260]{position:absolute;bottom:6px;left:6px;padding:3px 6px;border-radius:4px;background:#000c;font-size:9px;max-width:calc(100% - 56px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;animation:mjr-fade-in-15044260 .2s ease-out}@keyframes mjr-fade-in-15044260{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.mjr-gentime-badge[data-v-60fe3677]{position:absolute;bottom:6px;right:6px;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:800;letter-spacing:.015em;line-height:1.1;background:#0000008c;border:1px solid rgba(255,255,255,.12);pointer-events:none;z-index:10;box-shadow:0 2px 4px #0000004d;text-shadow:0 1px 2px rgba(0,0,0,.9),0 0 10px color-mix(in srgb,currentColor 35%,transparent);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px)}.mjr-popover-row[data-v-d1ebebd4]{display:grid;grid-template-columns:110px 1fr;gap:8px;align-items:center;margin-bottom:8px}.mjr-popover-row--3col[data-v-d1ebebd4]{grid-template-columns:110px 1fr 1fr}.mjr-popover-label[data-v-d1ebebd4]{font-size:12px;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)));white-space:nowrap}.mjr-popover-toggle[data-v-d1ebebd4]{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--fg-color, #e6edf7)}.mjr-filter-group-toggle[data-v-d1ebebd4]{width:100%;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:0;margin:0 0 10px;border:0;background:transparent;color:inherit;cursor:pointer;text-align:left}.mjr-filter-group-title[data-v-d1ebebd4]{font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.mjr-filter-group-chevron[data-v-d1ebebd4]{font-size:12px;line-height:1;color:var(--mjr-muted, var(--descrip-text, rgba(255, 255, 255, .65)))}.mjr-filter-group-body[data-v-d1ebebd4]{padding-top:2px}.mjr-filter-group[data-v-d1ebebd4]:not(.is-open){padding-bottom:8px}.mjr-filter-group:not(.is-open) .mjr-filter-group-toggle[data-v-d1ebebd4]{margin-bottom:0}.mjr-rating-star[data-v-6d012325]{outline:none}.mjr-rating-star[data-v-6d012325]:hover:not(:disabled){transform:scale(1.2)}.mjr-rating-star[data-v-6d012325]:focus-visible{outline:2px solid var(--focus-outline-color, #4dabf7);outline-offset:2px;border-radius:2px}.mjr-grid-skeleton-layout[data-v-d9ff3613]{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:12px}.mjr-grid-skeleton-card[data-v-d9ff3613]{border-radius:12px;border:1px solid rgba(255,255,255,.22);background:#c0c6ce33;overflow:hidden;min-height:146px;box-shadow:0 6px 18px #0000003d}.mjr-grid-skeleton-thumb[data-v-d9ff3613]{aspect-ratio:1 / 1;width:100%;background:linear-gradient(90deg,#9ca2ab85,#dce1e8b8 45%,#9ca2ab85);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-d9ff3613 1.2s linear infinite}.mjr-grid-skeleton-line[data-v-d9ff3613]{height:10px;margin:8px 10px;border-radius:8px;background:linear-gradient(90deg,#a4aab37a,#d6dbe2b3 45%,#a4aab37a);background-size:240% 100%;animation:mjr-grid-skeleton-shimmer-d9ff3613 1.2s linear infinite}.mjr-grid-skeleton-line--title[data-v-d9ff3613]{width:72%}.mjr-grid-skeleton-line--meta[data-v-d9ff3613]{width:48%;margin-top:2px;margin-bottom:10px}@keyframes mjr-grid-skeleton-shimmer-d9ff3613{0%{background-position:100% 0}to{background-position:-100% 0}}
@@ -1,6 +1,6 @@
import { p as Dt, M as Ut, c as zt, q as $t, t as Wt, A as q, u as w, v as I, x as Z, y as vt, z as Nt, B as Kt, C as Xt, D as Q, E as W, n as qt, s as st, F as dt, G as wt, H as ut, I as pt, d as it, J as Yt, K as Zt } from "./entry-DyjnHjNW.js";
import { ensureViewerMetadataAsset as tt } from "./genInfo-L1PrRwDk.js";
const A = Object.freeze({
import { p as Dt, M as Ut, c as zt, q as $t, t as Wt, A as q, u as w, v as I, x as Z, y as vt, z as Nt, B as Kt, C as Xt, D as Q, E as W, n as qt, s as st, F as dt, G as wt, H as ut, I as pt, d as it, J as Yt, K as Zt } from "./entry-Cn6KiPQv.js";
import { ensureViewerMetadataAsset as tt } from "./genInfo-DPaWrr2W.js";
const E = Object.freeze({
SIMPLE: "simple",
AB: "ab",
SIDE: "side",
@@ -89,18 +89,18 @@ function ct(t, e = null, { kind: n = "" } = {}) {
h.type = "button", h.className = "mjr-icon-btn mjr-mfv-simple-player-btn", h.setAttribute("aria-label", "Step back");
const p = document.createElement("i");
p.className = "pi pi-step-backward", p.setAttribute("aria-hidden", "true"), h.appendChild(p);
const _ = document.createElement("button");
_.type = "button", _.className = "mjr-icon-btn mjr-mfv-simple-player-btn", _.setAttribute("aria-label", "Step forward");
const g = document.createElement("button");
g.type = "button", g.className = "mjr-icon-btn mjr-mfv-simple-player-btn", g.setAttribute("aria-label", "Step forward");
const y = document.createElement("i");
y.className = "pi pi-step-forward", y.setAttribute("aria-hidden", "true"), _.appendChild(y);
y.className = "pi pi-step-forward", y.setAttribute("aria-hidden", "true"), g.appendChild(y);
const S = document.createElement("div");
S.className = "mjr-mfv-simple-player-time", S.textContent = "0:00 / 0:00";
const g = document.createElement("div");
g.className = "mjr-mfv-simple-player-frame", g.textContent = "F: 0", s || (g.style.display = "none");
const _ = document.createElement("div");
_.className = "mjr-mfv-simple-player-frame", _.textContent = "F: 0", s || (_.style.display = "none");
const C = document.createElement("button");
C.type = "button", C.className = "mjr-icon-btn mjr-mfv-simple-player-btn", C.setAttribute("aria-label", "Mute/Unmute");
const E = document.createElement("i");
if (E.className = "pi pi-volume-up", E.setAttribute("aria-hidden", "true"), C.appendChild(E), s || (h.disabled = !0, _.disabled = !0, h.classList.add("is-disabled"), _.classList.add("is-disabled")), u.appendChild(h), u.appendChild(f), u.appendChild(_), u.appendChild(S), u.appendChild(g), u.appendChild(C), r.appendChild(a), r.appendChild(u), d.appendChild(t), l) {
const A = document.createElement("i");
if (A.className = "pi pi-volume-up", A.setAttribute("aria-hidden", "true"), C.appendChild(A), s || (h.disabled = !0, g.disabled = !0, h.classList.add("is-disabled"), g.classList.add("is-disabled")), u.appendChild(h), u.appendChild(f), u.appendChild(g), u.appendChild(S), u.appendChild(_), u.appendChild(C), r.appendChild(a), r.appendChild(u), d.appendChild(t), l) {
const b = document.createElement("div");
b.className = "mjr-mfv-simple-player-audio-backdrop", b.textContent = String((e == null ? void 0 : e.filename) || "Audio"), d.appendChild(b);
}
@@ -110,28 +110,28 @@ function ct(t, e = null, { kind: n = "" } = {}) {
} catch (b) {
(ft = console.debug) == null || ft.call(console, b);
}
const P = c ? String((t == null ? void 0 : t.src) || "") : "";
let M = !1, T = "";
const M = c ? String((t == null ? void 0 : t.src) || "") : "";
let B = !1, T = "";
const k = () => {
if (i) {
m.className = t.paused ? "pi pi-play" : "pi pi-pause";
return;
}
if (c) {
m.className = M ? "pi pi-play" : "pi pi-pause";
m.className = B ? "pi pi-play" : "pi pi-pause";
return;
}
m.className = "pi pi-play";
}, R = () => {
if (t instanceof HTMLMediaElement) {
E.className = t.muted ? "pi pi-volume-off" : "pi pi-volume-up";
A.className = t.muted ? "pi pi-volume-off" : "pi pi-volume-up";
return;
}
E.className = "pi pi-volume-off", C.disabled = !0, C.classList.add("is-disabled");
A.className = "pi pi-volume-off", C.disabled = !0, C.classList.add("is-disabled");
}, L = () => {
if (!s || !(t instanceof HTMLVideoElement)) return;
const b = _t(t, o), B = Lt(t, o);
g.textContent = B > 0 ? `F: ${b}/${B}` : `F: ${b}`;
const b = _t(t, o), P = Lt(t, o);
_.textContent = P > 0 ? `F: ${b}/${P}` : `F: ${b}`;
}, D = () => {
const b = Math.max(0, Math.min(100, Number(a.value) / 1e3 * 100));
a.style.setProperty("--mjr-seek-pct", `${b}%`);
@@ -140,39 +140,39 @@ function ct(t, e = null, { kind: n = "" } = {}) {
S.textContent = c ? "Animated" : "Preview", a.value = "0", D();
return;
}
const b = Number(t.currentTime || 0), B = Number(t.duration || 0);
if (Number.isFinite(B) && B > 0) {
const x = Math.max(0, Math.min(1, b / B));
a.value = String(Math.round(x * 1e3)), S.textContent = `${lt(b)} / ${lt(B)}`;
const b = Number(t.currentTime || 0), P = Number(t.duration || 0);
if (Number.isFinite(P) && P > 0) {
const x = Math.max(0, Math.min(1, b / P));
a.value = String(Math.round(x * 1e3)), S.textContent = `${lt(b)} / ${lt(P)}`;
} else
a.value = "0", S.textContent = `${lt(b)} / 0:00`;
D();
}, K = (b) => {
var B;
var P;
try {
(B = b == null ? void 0 : b.stopPropagation) == null || B.call(b);
(P = b == null ? void 0 : b.stopPropagation) == null || P.call(b);
} catch {
}
}, mt = (b) => {
var B, x;
var P, x;
K(b);
try {
if (i)
t.paused ? ht(t) : (B = t.pause) == null || B.call(t);
t.paused ? ht(t) : (P = t.pause) == null || P.call(t);
else if (c)
if (!M)
T || (T = le(t)), T && (t.src = T), M = !0;
if (!B)
T || (T = le(t)), T && (t.src = T), B = !0;
else {
const j = P ? `${P}${P.includes("?") ? "&" : "?"}mjr_anim=${Date.now()}` : t.src;
t.src = j, M = !1;
const j = M ? `${M}${M.includes("?") ? "&" : "?"}mjr_anim=${Date.now()}` : t.src;
t.src = j, B = !1;
}
} catch (j) {
(x = console.debug) == null || x.call(console, j);
}
k();
}, Y = (b, B) => {
}, Y = (b, P) => {
var j, V;
if (K(B), !s || !(t instanceof HTMLVideoElement)) return;
if (K(P), !s || !(t instanceof HTMLVideoElement)) return;
try {
(j = t.pause) == null || j.call(t);
} catch (ot) {
@@ -181,12 +181,12 @@ function ct(t, e = null, { kind: n = "" } = {}) {
const x = _t(t, o);
oe(t, x + b, o), k(), L(), G();
}, Gt = (b) => {
var B;
var P;
if (K(b), t instanceof HTMLMediaElement) {
try {
t.muted = !t.muted;
} catch (x) {
(B = console.debug) == null || B.call(console, x);
(P = console.debug) == null || P.call(console, x);
}
R();
}
@@ -194,43 +194,43 @@ function ct(t, e = null, { kind: n = "" } = {}) {
var j;
if (K(b), !i) return;
D();
const B = Number(t.duration || 0);
if (!Number.isFinite(B) || B <= 0) return;
const P = Number(t.duration || 0);
if (!Number.isFinite(P) || P <= 0) return;
const x = Math.max(0, Math.min(1, Number(a.value) / 1e3));
try {
t.currentTime = B * x;
t.currentTime = P * x;
} catch (V) {
(j = console.debug) == null || j.call(console, V);
}
L(), G();
}, nt = (b) => K(b), Rt = (b) => {
var B, x, j, V;
var P, x, j, V;
try {
if ((x = (B = b == null ? void 0 : b.target) == null ? void 0 : B.closest) != null && x.call(B, "button, input, textarea, select")) return;
if ((x = (P = b == null ? void 0 : b.target) == null ? void 0 : P.closest) != null && x.call(P, "button, input, textarea, select")) return;
(j = d.focus) == null || j.call(d, { preventScroll: !0 });
} catch (ot) {
(V = console.debug) == null || V.call(console, ot);
}
}, Ht = (b) => {
var x, j, V;
const B = String((b == null ? void 0 : b.key) || "");
if (!(!B || b != null && b.altKey || b != null && b.ctrlKey || b != null && b.metaKey)) {
if (B === " " || B === "Spacebar") {
const P = String((b == null ? void 0 : b.key) || "");
if (!(!P || b != null && b.altKey || b != null && b.ctrlKey || b != null && b.metaKey)) {
if (P === " " || P === "Spacebar") {
(x = b.preventDefault) == null || x.call(b), mt(b);
return;
}
if (B === "ArrowLeft") {
if (P === "ArrowLeft") {
if (!s) return;
(j = b.preventDefault) == null || j.call(b), Y(-1, b);
return;
}
if (B === "ArrowRight") {
if (P === "ArrowRight") {
if (!s) return;
(V = b.preventDefault) == null || V.call(b), Y(1, b);
}
}
};
return f.addEventListener("click", mt), h.addEventListener("click", (b) => Y(-1, b)), _.addEventListener("click", (b) => Y(1, b)), C.addEventListener("click", Gt), a.addEventListener("input", Ot), r.addEventListener("pointerdown", nt), r.addEventListener("click", nt), r.addEventListener("dblclick", nt), d.addEventListener("pointerdown", Rt), d.addEventListener("keydown", Ht), t instanceof HTMLMediaElement && (t.addEventListener("play", k, { passive: !0 }), t.addEventListener("pause", k, { passive: !0 }), t.addEventListener(
return f.addEventListener("click", mt), h.addEventListener("click", (b) => Y(-1, b)), g.addEventListener("click", (b) => Y(1, b)), C.addEventListener("click", Gt), a.addEventListener("input", Ot), r.addEventListener("pointerdown", nt), r.addEventListener("click", nt), r.addEventListener("dblclick", nt), d.addEventListener("pointerdown", Rt), d.addEventListener("keydown", Ht), t instanceof HTMLMediaElement && (t.addEventListener("play", k, { passive: !0 }), t.addEventListener("pause", k, { passive: !0 }), t.addEventListener(
"timeupdate",
() => {
L(), G();
@@ -533,11 +533,11 @@ class Se {
h.className = m ? "mjr-ws-widget-row mjr-ws-widget-row--block" : "mjr-ws-widget-row";
const p = document.createElement("label");
p.className = "mjr-ws-widget-label", p.textContent = u.name || "";
const _ = document.createElement("div");
_.className = "mjr-ws-widget-input";
const g = document.createElement("div");
g.className = "mjr-ws-widget-input";
const y = fe(u, () => {
});
_.appendChild(y), this._inputMap.set(u.name, y), h.appendChild(p), h.appendChild(_), c.appendChild(h);
g.appendChild(y), this._inputMap.set(u.name, y), h.appendChild(p), h.appendChild(g), c.appendChild(h);
}
if (!r) {
const u = document.createElement("div");
@@ -721,30 +721,30 @@ function Ne() {
function a() {
r != null && (clearTimeout(r), r = null);
}
function u(g, { canStop: C = !1 } = {}) {
l = g, e.classList.toggle("running", l === N.RUNNING), e.classList.toggle("stopping", l === N.STOPPING), e.classList.toggle("error", l === N.ERROR), e.disabled = l === N.RUNNING || l === N.STOPPING, i.disabled = !C || l === N.STOPPING, i.classList.toggle("active", C && l !== N.STOPPING), i.classList.toggle("stopping", l === N.STOPPING), l === N.RUNNING || l === N.STOPPING ? o.className = "pi pi-spin pi-spinner" : o.className = "pi pi-play";
function u(_, { canStop: C = !1 } = {}) {
l = _, e.classList.toggle("running", l === N.RUNNING), e.classList.toggle("stopping", l === N.STOPPING), e.classList.toggle("error", l === N.ERROR), e.disabled = l === N.RUNNING || l === N.STOPPING, i.disabled = !C || l === N.STOPPING, i.classList.toggle("active", C && l !== N.STOPPING), i.classList.toggle("stopping", l === N.STOPPING), l === N.RUNNING || l === N.STOPPING ? o.className = "pi pi-spin pi-spinner" : o.className = "pi pi-play";
}
function f() {
const g = I("tooltip.queueStop", "Stop Generation");
i.title = g, i.setAttribute("aria-label", g);
const _ = I("tooltip.queueStop", "Stop Generation");
i.title = _, i.setAttribute("aria-label", _);
}
function m(g = Z.getSnapshot(), { authoritative: C = !1 } = {}) {
const E = Math.max(0, Number(g == null ? void 0 : g.queue) || 0), P = (g == null ? void 0 : g.prompt) || null, M = !!(P != null && P.currentlyExecuting), T = !!(P && (P.currentlyExecuting || P.errorDetails)), k = E > 0 || T, R = !!(P != null && P.errorDetails);
C && E === 0 && !P && (c = !1, d = !1);
const L = c || d || M || E > 0;
if ((M || k || E > 0) && (c = !1), R) {
function m(_ = Z.getSnapshot(), { authoritative: C = !1 } = {}) {
const A = Math.max(0, Number(_ == null ? void 0 : _.queue) || 0), M = (_ == null ? void 0 : _.prompt) || null, B = !!(M != null && M.currentlyExecuting), T = !!(M && (M.currentlyExecuting || M.errorDetails)), k = A > 0 || T, R = !!(M != null && M.errorDetails);
C && A === 0 && !M && (c = !1, d = !1);
const L = c || d || B || A > 0;
if ((B || k || A > 0) && (c = !1), R) {
d = !1, a(), u(N.ERROR, { canStop: !1 });
return;
}
if (d) {
if (!L) {
d = !1, m(g);
d = !1, m(_);
return;
}
u(N.STOPPING, { canStop: !1 });
return;
}
if (c || M || k || E > 0) {
if (c || B || k || A > 0) {
a(), u(N.RUNNING, { canStop: !0 });
return;
}
@@ -756,56 +756,56 @@ function Ne() {
}, 1500);
}
async function p() {
const g = w(), C = Nt(g);
if (C && typeof C.interrupt == "function")
return await C.interrupt(), { tracked: !0 };
if (C && typeof C.fetchApi == "function") {
const P = await C.fetchApi("/interrupt", { method: "POST" });
if (!(P != null && P.ok)) throw new Error(`POST /interrupt failed (${P == null ? void 0 : P.status})`);
const _ = w(), A = (_ != null && _.api && typeof _.api.interrupt == "function" ? _.api : null) ?? Nt(_);
if (A && typeof A.interrupt == "function")
return await A.interrupt(), { tracked: !0 };
if (A && typeof A.fetchApi == "function") {
const B = await A.fetchApi("/interrupt", { method: "POST" });
if (!(B != null && B.ok)) throw new Error(`POST /interrupt failed (${B == null ? void 0 : B.status})`);
return { tracked: !0 };
}
const E = await fetch("/interrupt", { method: "POST" });
if (!E.ok) throw new Error(`POST /interrupt failed (${E.status})`);
const M = await fetch("/interrupt", { method: "POST", credentials: "include" });
if (!M.ok) throw new Error(`POST /interrupt failed (${M.status})`);
return { tracked: !1 };
}
async function _() {
var g;
async function g() {
var _;
if (!(l === N.RUNNING || l === N.STOPPING)) {
c = !0, d = !1, m();
try {
const C = await Ie();
C != null && C.tracked || (c = !1), m();
} catch (C) {
(g = console.error) == null || g.call(console, "[MFV Run]", C), h();
(_ = console.error) == null || _.call(console, "[MFV Run]", C), h();
}
}
}
async function y() {
var g;
var _;
if (l === N.RUNNING) {
d = !0, m();
try {
const C = await p();
C != null && C.tracked || (d = !1, c = !1), m();
} catch (C) {
(g = console.error) == null || g.call(console, "[MFV Stop]", C), d = !1, m();
(_ = console.error) == null || _.call(console, "[MFV Stop]", C), d = !1, m();
} finally {
}
}
}
f(), i.disabled = !0, e.addEventListener("click", _), i.addEventListener("click", y);
const S = (g) => {
m((g == null ? void 0 : g.detail) || Z.getSnapshot(), {
f(), i.disabled = !0, e.addEventListener("click", g), i.addEventListener("click", y);
const S = (_) => {
m((_ == null ? void 0 : _.detail) || Z.getSnapshot(), {
authoritative: !0
});
};
return Z.addEventListener(Ct, S), vt({ timeoutMs: 4e3 }).catch((g) => {
return Z.addEventListener(Ct, S), vt({ timeoutMs: 4e3 }).catch((_) => {
var C;
(C = console.debug) == null || C.call(console, g);
(C = console.debug) == null || C.call(console, _);
}), m(), {
el: t,
dispose() {
a(), e.removeEventListener("click", _), i.removeEventListener("click", y), Z.removeEventListener(
a(), e.removeEventListener("click", g), i.removeEventListener("click", y), Z.removeEventListener(
Ct,
S
);
@@ -856,32 +856,35 @@ async function Ie() {
if (!t) throw new Error("ComfyUI app not available");
const e = typeof t.graphToPrompt == "function" ? await t.graphToPrompt() : null;
if (!(e != null && e.output)) throw new Error("graphToPrompt returned empty output");
const n = Nt(t);
if (n && typeof n.queuePrompt == "function")
return await n.queuePrompt(0, Tt(e)), { tracked: !0 };
if (n && typeof n.fetchApi == "function") {
const i = await n.fetchApi("/prompt", {
const o = (t != null && t.api && typeof t.api.queuePrompt == "function" ? t.api : null) ?? Nt(t);
if (o && typeof o.queuePrompt == "function")
return await o.queuePrompt(0, Tt(e)), { tracked: !0 };
if (o && typeof o.fetchApi == "function") {
const s = await o.fetchApi("/prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
St(e, {
clientId: At(n, t)
clientId: At(o, t)
})
)
});
if (!(i != null && i.ok)) throw new Error(`POST /prompt failed (${i == null ? void 0 : i.status})`);
if (!(s != null && s.ok)) throw new Error(`POST /prompt failed (${s == null ? void 0 : s.status})`);
return { tracked: !0 };
}
const o = await fetch("/prompt", {
if (typeof t.queuePrompt == "function")
return await t.queuePrompt(0), { tracked: !0 };
const i = await fetch("/prompt", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
St(e, {
clientId: At(n, t)
clientId: At(null, t)
})
)
});
if (!o.ok) throw new Error(`POST /prompt failed (${o.status})`);
if (!i.ok) throw new Error(`POST /prompt failed (${i.status})`);
return { tracked: !1 };
}
function Le(t) {
@@ -898,7 +901,7 @@ function ke(t) {
const e = document.createElement("div");
e.className = "mjr-mfv-header";
const n = document.createElement("span");
n.className = "mjr-mfv-header-title", n.id = t._titleId, n.textContent = "Majoor Viewer Lite";
n.className = "mjr-mfv-header-title", n.id = t._titleId, n.textContent = "〽️ Majoor Viewer Lite";
const o = document.createElement("button");
t._closeBtn = o, o.type = "button", o.className = "mjr-icon-btn mjr-mfv-close-btn", Q(o, I("tooltip.closeViewer", "Close viewer"), ee);
const i = document.createElement("i");
@@ -946,10 +949,10 @@ function xe(t) {
t._settingsBtn.title = r, t._settingsBtn.setAttribute("aria-label", r), t._settingsBtn.setAttribute("aria-pressed", "false");
const a = document.createElement("i");
return a.className = "pi pi-sliders-h", a.setAttribute("aria-hidden", "true"), t._settingsBtn.appendChild(a), e.appendChild(t._settingsBtn), t._runHandle = Ne(), e.appendChild(t._runHandle.el), t._handleDocClick = (m) => {
var p, _;
var p, g;
if (!t._genDropdown) return;
const h = m == null ? void 0 : m.target;
(_ = (p = t._genBtn) == null ? void 0 : p.contains) != null && _.call(p, h) || t._genDropdown.contains(h) || t._closeGenDropdown();
(g = (p = t._genBtn) == null ? void 0 : p.contains) != null && g.call(p, h) || t._genDropdown.contains(h) || t._closeGenDropdown();
}, t._bindDocumentUiHandlers(), e;
}
function je(t) {
@@ -973,8 +976,8 @@ function je(t) {
var y, S;
const p = (S = (y = h.target) == null ? void 0 : y.closest) == null ? void 0 : S.call(y, ".mjr-mfv-pin-btn");
if (!p) return;
const _ = p.dataset.slot;
_ && (t._pinnedSlots.has(_) ? t._pinnedSlots.delete(_) : t._pinnedSlots.add(_), t._pinnedSlots.has("C") || t._pinnedSlots.has("D") ? t._mode !== A.GRID && t.setMode(A.GRID) : t._pinnedSlots.size > 0 && t._mode === A.SIMPLE && t.setMode(A.AB), t._updatePinUI());
const g = p.dataset.slot;
g && (t._pinnedSlots.has(g) ? t._pinnedSlots.delete(g) : t._pinnedSlots.add(g), t._pinnedSlots.has("C") || t._pinnedSlots.has("D") ? t._mode !== E.GRID && t.setMode(E.GRID) : t._pinnedSlots.size > 0 && t._mode === E.SIMPLE && t.setMode(E.AB), t._updatePinUI());
},
{ signal: e }
), (c = t._liveBtn) == null || c.addEventListener(
@@ -998,8 +1001,8 @@ function je(t) {
), (a = t._genBtn) == null || a.addEventListener(
"click",
(h) => {
var p, _;
h.stopPropagation(), (_ = (p = t._genDropdown) == null ? void 0 : p.classList) != null && _.contains("is-visible") ? t._closeGenDropdown() : t._openGenDropdown();
var p, g;
h.stopPropagation(), (g = (p = t._genDropdown) == null ? void 0 : p.classList) != null && g.contains("is-visible") ? t._closeGenDropdown() : t._openGenDropdown();
},
{ signal: e }
), (u = t._popoutBtn) == null || u.addEventListener(
@@ -1207,11 +1210,11 @@ function Xe(t) {
}
}
function qe(t) {
const e = [A.SIMPLE, A.AB, A.SIDE, A.GRID];
const e = [E.SIMPLE, E.AB, E.SIDE, E.GRID];
t._mode = e[(e.indexOf(t._mode) + 1) % e.length], t._updateModeBtnUI(), t._refresh(), t._notifyModeChanged();
}
function we(t, e) {
Object.values(A).includes(e) && (t._mode = e, t._updateModeBtnUI(), t._refresh(), t._notifyModeChanged());
Object.values(E).includes(e) && (t._mode = e, t._updateModeBtnUI(), t._refresh(), t._notifyModeChanged());
}
function Ye(t) {
return t._pinnedSlots;
@@ -1228,10 +1231,10 @@ function Ze(t) {
function Je(t) {
if (!t._modeBtn) return;
const e = {
[A.SIMPLE]: { icon: "pi-image", label: "Mode: Simple - click to switch" },
[A.AB]: { icon: "pi-clone", label: "Mode: A/B Compare - click to switch" },
[A.SIDE]: { icon: "pi-table", label: "Mode: Side-by-Side - click to switch" },
[A.GRID]: {
[E.SIMPLE]: { icon: "pi-image", label: "Mode: Simple - click to switch" },
[E.AB]: { icon: "pi-clone", label: "Mode: A/B Compare - click to switch" },
[E.SIDE]: { icon: "pi-table", label: "Mode: Side-by-Side - click to switch" },
[E.GRID]: {
icon: "pi-th-large",
label: "Mode: Grid Compare (up to 4) - click to switch"
}
@@ -1270,14 +1273,14 @@ function en(t, e, n = {}) {
_isPreview: !0,
_sourceLabel: (n == null ? void 0 : n.sourceLabel) || null
};
if (t._mode === A.AB || t._mode === A.SIDE || t._mode === A.GRID) {
if (t._mode === E.AB || t._mode === E.SIDE || t._mode === E.GRID) {
const l = t.getPinnedSlots();
if (t._mode === A.GRID) {
if (t._mode === E.GRID) {
const c = ["A", "B", "C", "D"].find((d) => !l.has(d)) || "A";
t[`_media${c}`] = i;
} else l.has("B") ? t._mediaA = i : t._mediaB = i;
} else
t._mediaA = i, t._resetMfvZoom(), t._mode !== A.SIMPLE && (t._mode = A.SIMPLE, t._updateModeBtnUI());
t._mediaA = i, t._resetMfvZoom(), t._mode !== E.SIMPLE && (t._mode = E.SIMPLE, t._updateModeBtnUI());
++t._refreshGen, t._refresh();
}
function nn(t) {
@@ -1495,7 +1498,7 @@ function un(t) {
if (!(n && (F("electron-popup-request", { width: s, height: l }), t._tryElectronPopupFallback(e, s, l, new Error("Desktop popup requested"))))) {
if (n && "documentPictureInPicture" in window) {
F("electron-pip-request", { width: s, height: l }), window.documentPictureInPicture.requestWindow({ width: s, height: l }).then((r) => {
var h, p, _;
var h, p, g;
F("electron-pip-opened", {
hasDocument: !!(r != null && r.document)
}), t._popoutWindow = r, t._isPopped = !0, t._popoutRestoreGuard = !1;
@@ -1528,7 +1531,7 @@ function un(t) {
try {
r.close();
} catch (S) {
(_ = console.debug) == null || _.call(console, S);
(g = console.debug) == null || g.call(console, S);
}
t._activateDesktopExpandedFallback(y);
return;
@@ -1536,8 +1539,8 @@ function un(t) {
e.classList.add("is-visible"), t.isVisible = !0, t._resetGenDropdownForCurrentDocument(), t._rebindControlHandlers(), t._bindDocumentUiHandlers(), t._updatePopoutBtnUI(), F("electron-pip-ready", { isPopped: t._isPopped }), r.addEventListener("pagehide", u, {
signal: a
}), t._startPopoutCloseWatch(), t._popoutKeydownHandler = (y) => {
var g, C;
const S = String(((g = y == null ? void 0 : y.target) == null ? void 0 : g.tagName) || "").toLowerCase();
var _, C;
const S = String(((_ = y == null ? void 0 : y.target) == null ? void 0 : _.tagName) || "").toLowerCase();
y != null && y.defaultPrevented || (C = y == null ? void 0 : y.target) != null && C.isContentEditable || S === "input" || S === "textarea" || S === "select" || t._forwardKeydownToController(y);
}, r.addEventListener("keydown", t._popoutKeydownHandler, {
signal: a
@@ -1586,10 +1589,10 @@ function pn(t, e, n, o) {
}
if (!p) return;
p.title = "Majoor Viewer", t._installPopoutStyles(p);
const _ = Vt(p);
if (_) {
const g = Vt(p);
if (g) {
try {
_.appendChild(p.adoptNode(e));
g.appendChild(p.adoptNode(e));
} catch (y) {
console.warn("[MFV] adoptNode failed", y);
return;
@@ -1603,17 +1606,17 @@ function pn(t, e, n, o) {
(m = console.debug) == null || m.call(console, "[MFV] immediate mount failed, retrying on load", p);
try {
c.addEventListener("load", a, { signal: d });
} catch (_) {
(h = console.debug) == null || h.call(console, "[MFV] pop-out page load listener failed", _);
} catch (g) {
(h = console.debug) == null || h.call(console, "[MFV] pop-out page load listener failed", g);
}
}
c.addEventListener("beforeunload", r, { signal: d }), c.addEventListener("pagehide", r, { signal: d }), c.addEventListener("unload", r, { signal: d }), t._startPopoutCloseWatch(), t._popoutKeydownHandler = (p) => {
var S, g, C, E;
const _ = String(((S = p == null ? void 0 : p.target) == null ? void 0 : S.tagName) || "").toLowerCase();
if (p != null && p.defaultPrevented || (g = p == null ? void 0 : p.target) != null && g.isContentEditable || _ === "input" || _ === "textarea" || _ === "select")
var S, _, C, A;
const g = String(((S = p == null ? void 0 : p.target) == null ? void 0 : S.tagName) || "").toLowerCase();
if (p != null && p.defaultPrevented || (_ = p == null ? void 0 : p.target) != null && _.isContentEditable || g === "input" || g === "textarea" || g === "select")
return;
if (String((p == null ? void 0 : p.key) || "").toLowerCase() === "v" && (p != null && p.ctrlKey || p != null && p.metaKey) && !(p != null && p.altKey) && !(p != null && p.shiftKey)) {
p.preventDefault(), (C = p.stopPropagation) == null || C.call(p), (E = p.stopImmediatePropagation) == null || E.call(p), t._dispatchControllerAction("toggle", W.MFV_TOGGLE);
p.preventDefault(), (C = p.stopPropagation) == null || C.call(p), (A = p.stopImmediatePropagation) == null || A.call(p), t._dispatchControllerAction("toggle", W.MFV_TOGGLE);
return;
}
t._forwardKeydownToController(p);
@@ -1844,8 +1847,8 @@ function En(t, e) {
if (!t.element || t._isPopped) return;
const r = t._resizeState;
if (!r) {
const _ = n(d);
t.element.style.cursor = _ ? t._resizeCursorForDirection(_) : "";
const g = n(d);
t.element.style.cursor = g ? t._resizeCursorForDirection(g) : "";
return;
}
if (r.pointerId !== d.pointerId) return;
@@ -1888,11 +1891,11 @@ function Pn(t, e) {
const p = Math.min(
window.innerWidth - d.offsetWidth,
Math.max(0, h.clientX - a)
), _ = Math.min(
), g = Math.min(
window.innerHeight - d.offsetHeight,
Math.max(0, h.clientY - u)
);
d.style.left = `${p}px`, d.style.top = `${_}px`, d.style.right = "auto", d.style.bottom = "auto";
d.style.left = `${p}px`, d.style.top = `${g}px`, d.style.right = "auto", d.style.bottom = "auto";
}, m = () => {
try {
o == null || o.abort();
@@ -1905,7 +1908,7 @@ function Pn(t, e) {
);
}
async function Bn(t, e, n, o, i, s, l, c) {
var m, h, p, _, y;
var m, h, p, g, y;
if (!n) return;
const d = H(n);
let r = null;
@@ -1914,14 +1917,14 @@ async function Bn(t, e, n, o, i, s, l, c) {
S && (r = ((p = (h = t._contentEl) == null ? void 0 : h.querySelector) == null ? void 0 : p.call(
h,
`.mjr-model3d-render-canvas[data-mjr-asset-id="${S}"]`
)) || null), r || (r = ((y = (_ = t._contentEl) == null ? void 0 : _.querySelector) == null ? void 0 : y.call(_, ".mjr-model3d-render-canvas")) || null);
)) || null), r || (r = ((y = (g = t._contentEl) == null ? void 0 : g.querySelector) == null ? void 0 : y.call(g, ".mjr-model3d-render-canvas")) || null);
}
if (!r) {
const S = xt(n);
if (!S) return;
r = await new Promise((g) => {
r = await new Promise((_) => {
const C = new Image();
C.crossOrigin = "anonymous", C.onload = () => g(C), C.onerror = () => g(null), C.src = S;
C.crossOrigin = "anonymous", C.onload = () => _(C), C.onerror = () => _(null), C.src = S;
});
}
if (!r) return;
@@ -1960,11 +1963,11 @@ function Mn(t, e, n, o) {
e.font = `bold ${l}px system-ui, sans-serif`;
const p = e.measureText(h).width;
e.font = `${l}px system-ui, sans-serif`;
const _ = Math.max(32, r - d * 2 - p);
const g = Math.max(32, r - d * 2 - p);
let y = 0, S = "";
for (const g of f.split(" ")) {
const C = S ? S + " " + g : g;
e.measureText(C).width > _ && S ? (y += 1, S = g) : S = C;
for (const _ of f.split(" ")) {
const C = S ? S + " " + _ : _;
e.measureText(C).width > g && S ? (y += 1, S = _) : S = C;
}
S && (y += 1), a += y;
}
@@ -1993,39 +1996,39 @@ function Nn(t, e, n, o, i, s, l) {
"step",
"genTime"
], a = [];
for (const E of r) {
if (!t._genInfoSelections.has(E)) continue;
const P = c[E] != null ? String(c[E]) : "";
if (!P) continue;
let M = E.charAt(0).toUpperCase() + E.slice(1);
E === "lora" ? M = "LoRA" : E === "cfg" ? M = "CFG" : E === "genTime" && (M = "Gen Time"), a.push({
label: `${M}: `,
value: P,
color: d[E] || "#ffffff"
for (const A of r) {
if (!t._genInfoSelections.has(A)) continue;
const M = c[A] != null ? String(c[A]) : "";
if (!M) continue;
let B = A.charAt(0).toUpperCase() + A.slice(1);
A === "lora" ? B = "LoRA" : A === "cfg" ? B = "CFG" : A === "genTime" && (B = "Gen Time"), a.push({
label: `${B}: `,
value: M,
color: d[A] || "#ffffff"
});
}
if (!a.length) return;
const u = 11, f = 16, m = 8, h = Math.max(100, s - m * 2);
e.save();
const p = [];
for (const { label: E, value: P, color: M } of a) {
for (const { label: A, value: M, color: B } of a) {
e.font = `bold ${u}px system-ui, sans-serif`;
const T = e.measureText(E).width;
const T = e.measureText(A).width;
e.font = `${u}px system-ui, sans-serif`;
const k = h - m * 2 - T, R = [];
let L = "";
for (const D of P.split(" ")) {
for (const D of M.split(" ")) {
const G = L ? L + " " + D : D;
e.measureText(G).width > k && L ? (R.push(L), L = D) : L = G;
}
L && R.push(L), p.push({ label: E, labelW: T, lines: R, color: M });
L && R.push(L), p.push({ label: A, labelW: T, lines: R, color: B });
}
const y = p.reduce((E, P) => E + P.lines.length, 0) * f + m * 2, S = o + m, g = i + l - y - m;
e.globalAlpha = 0.72, e.fillStyle = "#000", jt(e, S, g, h, y, 6), e.fill(), e.globalAlpha = 1;
let C = g + m + u;
for (const { label: E, labelW: P, lines: M, color: T } of p)
for (let k = 0; k < M.length; k++)
k === 0 ? (e.font = `bold ${u}px system-ui, sans-serif`, e.fillStyle = T, e.fillText(E, S + m, C), e.font = `${u}px system-ui, sans-serif`, e.fillStyle = "rgba(255,255,255,0.88)", e.fillText(M[k], S + m + P, C)) : (e.font = `${u}px system-ui, sans-serif`, e.fillStyle = "rgba(255,255,255,0.88)", e.fillText(M[k], S + m + P, C)), C += f;
const y = p.reduce((A, M) => A + M.lines.length, 0) * f + m * 2, S = o + m, _ = i + l - y - m;
e.globalAlpha = 0.72, e.fillStyle = "#000", jt(e, S, _, h, y, 6), e.fill(), e.globalAlpha = 1;
let C = _ + m + u;
for (const { label: A, labelW: M, lines: B, color: T } of p)
for (let k = 0; k < B.length; k++)
k === 0 ? (e.font = `bold ${u}px system-ui, sans-serif`, e.fillStyle = T, e.fillText(A, S + m, C), e.font = `${u}px system-ui, sans-serif`, e.fillStyle = "rgba(255,255,255,0.88)", e.fillText(B[k], S + m + M, C)) : (e.font = `${u}px system-ui, sans-serif`, e.fillStyle = "rgba(255,255,255,0.88)", e.fillText(B[k], S + m + M, C)), C += f;
e.restore();
}
async function Fn(t) {
@@ -2034,7 +2037,7 @@ async function Fn(t) {
t._captureBtn && (t._captureBtn.disabled = !0, t._captureBtn.setAttribute("aria-label", I("tooltip.capturingView", "Capturing…")));
const e = t._contentEl.clientWidth || 480, n = t._contentEl.clientHeight || 360;
let o = n;
if (t._mode === A.SIMPLE && t._mediaA && t._genInfoSelections.size) {
if (t._mode === E.SIMPLE && t._mediaA && t._genInfoSelections.size) {
const r = document.createElement("canvas");
r.width = e, r.height = n;
const a = r.getContext("2d"), u = t._estimateGenInfoOverlayHeight(a, t._mediaA, e);
@@ -2048,17 +2051,17 @@ async function Fn(t) {
const s = i.getContext("2d");
s.fillStyle = "#0d0d0d", s.fillRect(0, 0, e, o);
try {
if (t._mode === A.SIMPLE)
if (t._mode === E.SIMPLE)
t._mediaA && (await t._drawMediaFit(s, t._mediaA, 0, 0, e, n), t._drawGenInfoOverlay(s, t._mediaA, 0, 0, e, o));
else if (t._mode === A.AB) {
else if (t._mode === E.AB) {
const r = Math.round(t._abDividerX * e), a = t._contentEl.querySelector(
".mjr-mfv-ab-layer:not(.mjr-mfv-ab-layer--b) video"
), u = t._contentEl.querySelector(".mjr-mfv-ab-layer--b video");
t._mediaA && await t._drawMediaFit(s, t._mediaA, 0, 0, e, o, a), t._mediaB && (s.save(), s.beginPath(), s.rect(r, 0, e - r, o), s.clip(), await t._drawMediaFit(s, t._mediaB, 0, 0, e, o, u), s.restore()), s.save(), s.strokeStyle = "rgba(255,255,255,0.88)", s.lineWidth = 2, s.beginPath(), s.moveTo(r, 0), s.lineTo(r, o), s.stroke(), s.restore(), X(s, "A", 8, 8), X(s, "B", r + 8, 8), t._mediaA && t._drawGenInfoOverlay(s, t._mediaA, 0, 0, r, o), t._mediaB && t._drawGenInfoOverlay(s, t._mediaB, r, 0, e - r, o);
} else if (t._mode === A.SIDE) {
} else if (t._mode === E.SIDE) {
const r = Math.floor(e / 2), a = t._contentEl.querySelector(".mjr-mfv-side-panel:first-child video"), u = t._contentEl.querySelector(".mjr-mfv-side-panel:last-child video");
t._mediaA && (await t._drawMediaFit(s, t._mediaA, 0, 0, r, o, a), t._drawGenInfoOverlay(s, t._mediaA, 0, 0, r, o)), s.fillStyle = "#111", s.fillRect(r, 0, 2, o), t._mediaB && (await t._drawMediaFit(s, t._mediaB, r, 0, r, o, u), t._drawGenInfoOverlay(s, t._mediaB, r, 0, r, o)), X(s, "A", 8, 8), X(s, "B", r + 8, 8);
} else if (t._mode === A.GRID) {
} else if (t._mode === E.GRID) {
const r = Math.floor(e / 2), a = Math.floor(o / 2), u = 1, f = [
{ media: t._mediaA, label: "A", x: 0, y: 0, w: r - u, h: a - u },
{
@@ -2087,8 +2090,8 @@ async function Fn(t) {
}
], m = t._contentEl.querySelectorAll(".mjr-mfv-grid-cell");
for (let h = 0; h < f.length; h++) {
const p = f[h], _ = ((d = m[h]) == null ? void 0 : d.querySelector("video")) || null;
p.media && (await t._drawMediaFit(s, p.media, p.x, p.y, p.w, p.h, _), t._drawGenInfoOverlay(s, p.media, p.x, p.y, p.w, p.h)), X(s, p.label, p.x + 8, p.y + 8);
const p = f[h], g = ((d = m[h]) == null ? void 0 : d.querySelector("video")) || null;
p.media && (await t._drawMediaFit(s, p.media, p.x, p.y, p.w, p.h, g), t._drawGenInfoOverlay(s, p.media, p.x, p.y, p.w, p.h)), X(s, p.label, p.x + 8, p.y + 8);
}
s.save(), s.fillStyle = "#111", s.fillRect(r - u, 0, u * 2, o), s.fillRect(0, a - u, e, u * 2), s.restore();
}
@@ -2096,9 +2099,9 @@ async function Fn(t) {
console.debug("[MFV] capture error:", r);
}
const c = `${{
[A.AB]: "mfv-ab",
[A.SIDE]: "mfv-side",
[A.GRID]: "mfv-grid"
[E.AB]: "mfv-ab",
[E.SIDE]: "mfv-side",
[E.GRID]: "mfv-grid"
}[t._mode] ?? "mfv"}-${Date.now()}.png`;
try {
const r = i.toDataURL("image/png"), a = document.createElement("a");
@@ -2110,7 +2113,7 @@ async function Fn(t) {
}
}
function In(t, e, { autoMode: n = !1 } = {}) {
if (t._mediaA = e || null, t._resetMfvZoom(), n && t._mode !== A.SIMPLE && (t._mode = A.SIMPLE, t._updateModeBtnUI()), t._mediaA && typeof tt == "function") {
if (t._mediaA = e || null, t._resetMfvZoom(), n && t._mode !== E.SIMPLE && (t._mode = E.SIMPLE, t._updateModeBtnUI()), t._mediaA && typeof tt == "function") {
const o = ++t._refreshGen;
(async () => {
var i;
@@ -2129,7 +2132,7 @@ function In(t, e, { autoMode: n = !1 } = {}) {
t._refresh();
}
function Ln(t, e, n) {
t._mediaA = e || null, t._mediaB = n || null, t._resetMfvZoom(), t._mode === A.SIMPLE && (t._mode = A.AB, t._updateModeBtnUI());
t._mediaA = e || null, t._mediaB = n || null, t._resetMfvZoom(), t._mode === E.SIMPLE && (t._mode = E.AB, t._updateModeBtnUI());
const o = ++t._refreshGen, i = async (s) => {
if (!s) return s;
try {
@@ -2147,7 +2150,7 @@ function Ln(t, e, n) {
})();
}
function kn(t, e, n, o, i) {
t._mediaA = e || null, t._mediaB = n || null, t._mediaC = o || null, t._mediaD = i || null, t._resetMfvZoom(), t._mode !== A.GRID && (t._mode = A.GRID, t._updateModeBtnUI());
t._mediaA = e || null, t._mediaB = n || null, t._mediaC = o || null, t._mediaD = i || null, t._resetMfvZoom(), t._mode !== E.GRID && (t._mode = E.GRID, t._updateModeBtnUI());
const s = ++t._refreshGen, l = async (c) => {
if (!c) return c;
try {
@@ -2180,7 +2183,7 @@ function $(t) {
let xn = 0;
class Vn {
constructor({ controller: e = null } = {}) {
this._instanceId = ++xn, this._controller = e && typeof e == "object" ? { ...e } : null, this.element = null, this.isVisible = !1, this._contentEl = null, this._closeBtn = null, this._modeBtn = null, this._pinGroup = null, this._pinBtns = null, this._liveBtn = null, this._genBtn = null, this._genDropdown = null, this._captureBtn = null, this._genInfoSelections = /* @__PURE__ */ new Set(["genTime"]), this._mode = A.SIMPLE, this._mediaA = null, this._mediaB = null, this._mediaC = null, this._mediaD = null, this._pinnedSlots = /* @__PURE__ */ new Set(), this._abDividerX = 0.5, this._zoom = 1, this._panX = 0, this._panY = 0, this._panzoomAC = null, this._dragging = !1, this._compareSyncAC = null, this._btnAC = null, this._refreshGen = 0, this._popoutWindow = null, this._popoutBtn = null, this._isPopped = !1, this._desktopExpanded = !1, this._desktopExpandRestore = null, this._desktopPopoutUnsupported = !1, this._popoutCloseHandler = null, this._popoutKeydownHandler = null, this._popoutCloseTimer = null, this._popoutRestoreGuard = !1, this._previewBtn = null, this._previewBlobUrl = null, this._previewActive = !1, this._nodeStreamBtn = null, this._nodeStreamActive = !1, this._docAC = new AbortController(), this._popoutAC = null, this._panelAC = new AbortController(), this._resizeState = null, this._titleId = `mjr-mfv-title-${this._instanceId}`, this._genDropdownId = `mjr-mfv-gen-dropdown-${this._instanceId}`, this._progressEl = null, this._progressNodesEl = null, this._progressStepsEl = null, this._progressTextEl = null, this._mediaProgressEl = null, this._mediaProgressTextEl = null, this._progressUpdateHandler = null, this._progressCurrentNodeId = null, this._docClickHost = null, this._handleDocClick = null;
this._instanceId = ++xn, this._controller = e && typeof e == "object" ? { ...e } : null, this.element = null, this.isVisible = !1, this._contentEl = null, this._closeBtn = null, this._modeBtn = null, this._pinGroup = null, this._pinBtns = null, this._liveBtn = null, this._genBtn = null, this._genDropdown = null, this._captureBtn = null, this._genInfoSelections = /* @__PURE__ */ new Set(["genTime"]), this._mode = E.SIMPLE, this._mediaA = null, this._mediaB = null, this._mediaC = null, this._mediaD = null, this._pinnedSlots = /* @__PURE__ */ new Set(), this._abDividerX = 0.5, this._zoom = 1, this._panX = 0, this._panY = 0, this._panzoomAC = null, this._dragging = !1, this._compareSyncAC = null, this._btnAC = null, this._refreshGen = 0, this._popoutWindow = null, this._popoutBtn = null, this._isPopped = !1, this._desktopExpanded = !1, this._desktopExpandRestore = null, this._desktopPopoutUnsupported = !1, this._popoutCloseHandler = null, this._popoutKeydownHandler = null, this._popoutCloseTimer = null, this._popoutRestoreGuard = !1, this._previewBtn = null, this._previewBlobUrl = null, this._previewActive = !1, this._nodeStreamBtn = null, this._nodeStreamActive = !1, this._docAC = new AbortController(), this._popoutAC = null, this._panelAC = new AbortController(), this._resizeState = null, this._titleId = `mjr-mfv-title-${this._instanceId}`, this._genDropdownId = `mjr-mfv-gen-dropdown-${this._instanceId}`, this._progressEl = null, this._progressNodesEl = null, this._progressStepsEl = null, this._progressTextEl = null, this._mediaProgressEl = null, this._mediaProgressTextEl = null, this._progressUpdateHandler = null, this._progressCurrentNodeId = null, this._docClickHost = null, this._handleDocClick = null;
}
_dispatchControllerAction(e, n) {
var o, i, s;
@@ -2364,8 +2367,8 @@ class Vn {
e.addEventListener(
"wheel",
(r) => {
var m, h, p, _;
if ((h = (m = r.target) == null ? void 0 : m.closest) != null && h.call(m, "audio") || (_ = (p = r.target) == null ? void 0 : p.closest) != null && _.call(p, ".mjr-mfv-simple-player-controls") || it(r.target)) return;
var m, h, p, g;
if ((h = (m = r.target) == null ? void 0 : m.closest) != null && h.call(m, "audio") || (g = (p = r.target) == null ? void 0 : p.closest) != null && g.call(p, ".mjr-mfv-simple-player-controls") || it(r.target)) return;
const a = ue(r.target, e);
if (a && pe(
a,
@@ -2383,13 +2386,13 @@ class Vn {
e.addEventListener(
"pointerdown",
(r) => {
var a, u, f, m, h, p, _, y, S;
if (!(r.button !== 0 && r.button !== 1) && !(this._zoom <= 1.01) && !((u = (a = r.target) == null ? void 0 : a.closest) != null && u.call(a, "video")) && !((m = (f = r.target) == null ? void 0 : f.closest) != null && m.call(f, "audio")) && !((p = (h = r.target) == null ? void 0 : h.closest) != null && p.call(h, ".mjr-mfv-simple-player-controls")) && !((y = (_ = r.target) == null ? void 0 : _.closest) != null && y.call(_, ".mjr-mfv-ab-divider")) && !it(r.target)) {
var a, u, f, m, h, p, g, y, S;
if (!(r.button !== 0 && r.button !== 1) && !(this._zoom <= 1.01) && !((u = (a = r.target) == null ? void 0 : a.closest) != null && u.call(a, "video")) && !((m = (f = r.target) == null ? void 0 : f.closest) != null && m.call(f, "audio")) && !((p = (h = r.target) == null ? void 0 : h.closest) != null && p.call(h, ".mjr-mfv-simple-player-controls")) && !((y = (g = r.target) == null ? void 0 : g.closest) != null && y.call(g, ".mjr-mfv-ab-divider")) && !it(r.target)) {
r.preventDefault(), o = !0, this._dragging = !0, i = r.clientX, s = r.clientY, l = this._panX, c = this._panY;
try {
e.setPointerCapture(r.pointerId);
} catch (g) {
(S = console.debug) == null || S.call(console, g);
} catch (_) {
(S = console.debug) == null || S.call(console, _);
}
this._applyTransform();
}
@@ -2417,8 +2420,8 @@ class Vn {
e.addEventListener("pointerup", d, n), e.addEventListener("pointercancel", d, n), e.addEventListener(
"dblclick",
(r) => {
var u, f, m, h, p, _;
if ((f = (u = r.target) == null ? void 0 : u.closest) != null && f.call(u, "video") || (h = (m = r.target) == null ? void 0 : m.closest) != null && h.call(m, "audio") || (_ = (p = r.target) == null ? void 0 : p.closest) != null && _.call(p, ".mjr-mfv-simple-player-controls") || it(r.target)) return;
var u, f, m, h, p, g;
if ((f = (u = r.target) == null ? void 0 : u.closest) != null && f.call(u, "video") || (h = (m = r.target) == null ? void 0 : m.closest) != null && h.call(m, "audio") || (g = (p = r.target) == null ? void 0 : p.closest) != null && g.call(p, ".mjr-mfv-simple-player-controls") || it(r.target)) return;
const a = Math.abs(this._zoom - 1) < 0.05;
this._setMfvZoom(a ? Math.min(4, this._zoom * 4) : 1, r.clientX, r.clientY);
},
@@ -2446,7 +2449,7 @@ class Vn {
}
_initCompareSync() {
var e;
if (this._destroyCompareSync(), !!this._contentEl && this._mode !== A.SIMPLE)
if (this._destroyCompareSync(), !!this._contentEl && this._mode !== E.SIMPLE)
try {
const n = Array.from(this._contentEl.querySelectorAll("video, audio"));
if (n.length < 2) return;
@@ -2461,16 +2464,16 @@ class Vn {
_refresh() {
if (this._contentEl) {
switch (this._destroyPanZoom(), this._destroyCompareSync(), this._contentEl.replaceChildren(), this._contentEl.style.overflow = "hidden", this._mode) {
case A.SIMPLE:
case E.SIMPLE:
this._renderSimple();
break;
case A.AB:
case E.AB:
this._renderAB();
break;
case A.SIDE:
case E.SIDE:
this._renderSide();
break;
case A.GRID:
case E.GRID:
this._renderGrid();
break;
}
@@ -2531,25 +2534,25 @@ class Vn {
let h = null;
r.addEventListener(
"pointerdown",
(_) => {
_.preventDefault(), r.setPointerCapture(_.pointerId);
(g) => {
g.preventDefault(), r.setPointerCapture(g.pointerId);
try {
h == null || h.abort();
} catch {
}
h = new AbortController();
const y = h.signal, S = s.getBoundingClientRect(), g = (E) => {
const P = Math.max(0.02, Math.min(0.98, (E.clientX - S.left) / S.width));
this._abDividerX = P;
const M = Math.round(P * 100);
c.style.clipPath = `inset(0 0 0 ${M}%)`, r.style.left = `${M}%`, u && (u.style.right = `calc(${100 - M}% + 8px)`), m && (m.style.left = `calc(${M}% + 8px)`);
const y = h.signal, S = s.getBoundingClientRect(), _ = (A) => {
const M = Math.max(0.02, Math.min(0.98, (A.clientX - S.left) / S.width));
this._abDividerX = M;
const B = Math.round(M * 100);
c.style.clipPath = `inset(0 0 0 ${B}%)`, r.style.left = `${B}%`, u && (u.style.right = `calc(${100 - B}% + 8px)`), m && (m.style.left = `calc(${B}% + 8px)`);
}, C = () => {
try {
h == null || h.abort();
} catch {
}
};
r.addEventListener("pointermove", g, { signal: y }), r.addEventListener("pointerup", C, { signal: y });
r.addEventListener("pointermove", _, { signal: y }), r.addEventListener("pointerup", C, { signal: y });
},
(p = this._panelAC) != null && p.signal ? { signal: this._panelAC.signal } : void 0
), s.appendChild(l), s.appendChild(c), s.appendChild(r), u && s.appendChild(u), m && s.appendChild(m), s.appendChild(U("A", "left")), s.appendChild(U("B", "right")), this._contentEl.appendChild(s);
@@ -2687,59 +2690,59 @@ class Vn {
return Fn(this);
}
dispose() {
var e, n, o, i, s, l, c, d, r, a, u, f, m, h, p, _, y, S;
var e, n, o, i, s, l, c, d, r, a, u, f, m, h, p, g, y, S;
Zt(this), this._destroyPanZoom(), this._destroyCompareSync(), this._stopEdgeResize(), this._clearPopoutCloseWatch();
try {
(e = this._panelAC) == null || e.abort(), this._panelAC = null;
} catch (g) {
(n = console.debug) == null || n.call(console, g);
} catch (_) {
(n = console.debug) == null || n.call(console, _);
}
try {
(o = this._btnAC) == null || o.abort(), this._btnAC = null;
} catch (g) {
(i = console.debug) == null || i.call(console, g);
} catch (_) {
(i = console.debug) == null || i.call(console, _);
}
try {
(s = this._docAC) == null || s.abort(), this._docAC = null;
} catch (g) {
(l = console.debug) == null || l.call(console, g);
} catch (_) {
(l = console.debug) == null || l.call(console, _);
}
try {
(c = this._popoutAC) == null || c.abort(), this._popoutAC = null;
} catch (g) {
(d = console.debug) == null || d.call(console, g);
} catch (_) {
(d = console.debug) == null || d.call(console, _);
}
try {
(r = this._panzoomAC) == null || r.abort(), this._panzoomAC = null;
} catch (g) {
(a = console.debug) == null || a.call(console, g);
} catch (_) {
(a = console.debug) == null || a.call(console, _);
}
try {
(f = (u = this._compareSyncAC) == null ? void 0 : u.abort) == null || f.call(u), this._compareSyncAC = null;
} catch (g) {
(m = console.debug) == null || m.call(console, g);
} catch (_) {
(m = console.debug) == null || m.call(console, _);
}
try {
this._isPopped && this.popIn();
} catch (g) {
(h = console.debug) == null || h.call(console, g);
} catch (_) {
(h = console.debug) == null || h.call(console, _);
}
this._revokePreviewBlob(), this._onSidebarPosChanged && (window.removeEventListener("mjr-settings-changed", this._onSidebarPosChanged), this._onSidebarPosChanged = null);
try {
(p = this.element) == null || p.remove();
} catch (g) {
(_ = console.debug) == null || _.call(console, g);
} catch (_) {
(g = console.debug) == null || g.call(console, _);
}
this.element = null, this._contentEl = null, this._closeBtn = null, this._modeBtn = null, this._pinGroup = null, this._pinBtns = null, this._liveBtn = null, this._nodeStreamBtn = null, this._popoutBtn = null, this._captureBtn = null, this._unbindDocumentUiHandlers();
try {
(y = this._genDropdown) == null || y.remove();
} catch (g) {
(S = console.debug) == null || S.call(console, g);
} catch (_) {
(S = console.debug) == null || S.call(console, _);
}
this._mediaA = null, this._mediaB = null, this._mediaC = null, this._mediaD = null, this.isVisible = !1;
}
}
export {
Vn as FloatingViewer,
A as MFV_MODES
E as MFV_MODES
};
@@ -1,4 +1,4 @@
import { E as S, w as L, e as s } from "./entry-DyjnHjNW.js";
import { E as S, w as L, e as s } from "./entry-Cn6KiPQv.js";
let l = null, c = null, d = null, m = !1, u = null, g = null, h = 0, _ = 0, f = null;
const v = /* @__PURE__ */ new WeakMap();
function k() {
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import { _ as nn, j as on, k as tn, A as B, l as en, m as rn, o as cn } from "./entry-DyjnHjNW.js";
import { _ as nn, j as on, k as tn, A as B, l as en, m as rn, o as cn } from "./entry-Cn6KiPQv.js";
import { o as E, c as R, t as $, a as O, b as I, n as ln, d as z, e as j } from "./mjr-vue-vendor-DZo-ZbFS.js";
const un = { style: { display: "flex", "flex-direction": "column", gap: "10px", "margin-bottom": "14px" } }, fn = {
key: 0,
@@ -1,4 +1,4 @@
import { M as s, a as o, P as D, b as t, c as M, f as d, g as l, i as E, d as r, r as i } from "./entry-DyjnHjNW.js";
import { M as s, a as o, P as D, b as t, c as M, f as d, g as l, i as E, d as r, r as i } from "./entry-Cn6KiPQv.js";
export {
s as MODEL3D_EXTS,
o as MODEL3D_EXT_TO_LOADER,
@@ -1,4 +1,4 @@
import { h as C } from "./entry-DyjnHjNW.js";
import { h as C } from "./entry-Cn6KiPQv.js";
function H(t, n, o) {
return 0.2126 * t + 0.7152 * n + 0.0722 * o;
}
@@ -1,4 +1,4 @@
import { i as q, n as Y, s as k } from "./entry-DyjnHjNW.js";
import { i as q, n as Y, s as k } from "./entry-Cn6KiPQv.js";
function Z(o) {
if (!o) return null;
try {
+1 -1
View File
@@ -1,2 +1,2 @@
import "./chunks/entry-DyjnHjNW.js";
import "./chunks/entry-Cn6KiPQv.js";
import "../../scripts/app.js";
@@ -231,7 +231,7 @@ def _provider_from_model_name(model: str) -> str | None:
"""Infer API provider from the model name string (secondary heuristic)."""
m = model.lower()
# Wan: only match when combined with "video" or "image" to avoid false positives
if "wan" in m and ("video" in m or "image" in m):
if "wan" in m:
return "alibaba_wan"
for keywords, provider in _MODEL_NAME_PROVIDERS:
if any(kw in m for kw in keywords):
@@ -528,8 +528,8 @@ def _populate_api_node_output(
"width": int(width), "height": int(height),
"confidence": "high", "source": source,
}
except Exception:
pass
except Exception as exc:
logger.debug("size conversion failed for node %s: %s", source, exc)
# Resolution string (e.g. "1080p", "720p") — convert to size when possible
resolution = ins.get("resolution")
@@ -210,12 +210,68 @@ def _resolve_lora_stacker_node(
active_loras.append(f"<lora:{name}:{strength}>")
return _join_text_fragments(active_loras, " ") or ""
def _resolve_text_generate_node(
nodes_by_id: dict[str, dict[str, Any]], ins: dict[str, Any], memo: set[str]
) -> str | None:
"""
TextGenerate is an LLM prompt-enhancer (e.g. Ernie Image). Its ``prompt``
input is typically a system-prompt template processed through a chain of
StringReplace nodes that substitute ``{prompt}``, ``{width}``, ``{height}``.
We trace the StringReplace chain upstream to extract the *original* user
prompt (the ``replace`` value whose ``find`` matches ``{prompt}``), rather
than the LLM-enhanced output.
"""
prompt_val = ins.get("prompt")
if isinstance(prompt_val, str) and prompt_val.strip():
return prompt_val.strip()
if not _is_link(prompt_val):
return None
user_prompt = _trace_user_prompt_in_replace_chain(nodes_by_id, prompt_val, memo)
if user_prompt:
return user_prompt
return _resolve_text_value(nodes_by_id, prompt_val, memo)
def _trace_user_prompt_in_replace_chain(
nodes_by_id: dict[str, dict[str, Any]], link: Any, memo: set[str], max_hops: int = 10
) -> str | None:
"""
Follow a chain of StringReplace nodes upstream from *link*. When one has a
``find`` value containing the word ``prompt`` (e.g. ``{prompt}``), return
the resolved ``replace`` value — that is the user's original prompt text.
"""
current_link = link
for _ in range(max_hops):
src_id = _walk_passthrough(nodes_by_id, current_link)
if not src_id or src_id in memo:
break
node = nodes_by_id.get(src_id)
if not isinstance(node, dict):
break
ct = _lower(_node_type(node))
if ct != "stringreplace":
break
node_ins = _inputs(node)
find_val = node_ins.get("find")
if isinstance(find_val, str) and "prompt" in find_val.lower():
return _resolve_text_value(nodes_by_id, node_ins.get("replace"), memo | {src_id})
string_link = node_ins.get("string")
if _is_link(string_link):
current_link = string_link
continue
break
return None
def _resolve_composed_string_from_node(
nodes_by_id: dict[str, dict[str, Any]], node: dict[str, Any], memo: set[str]
) -> str | None:
ct = _lower(_node_type(node))
ins = _inputs(node)
widgets = node.get("widgets_values")
if ct == "textgenerate":
return _resolve_text_generate_node(nodes_by_id, ins, memo)
preview_text = _resolve_preview_text(ins)
if preview_text is not None:
return preview_text
+15
View File
@@ -139,7 +139,14 @@ def _is_meaningful_token(token: str) -> bool:
return True
_SAFE_ALIAS_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_.]{0,60}$")
def _build_sort_sql(sort: str | None, *, table_alias: str = "a", rank_alias: str | None = None) -> str:
if not _SAFE_ALIAS_RE.fullmatch(table_alias):
raise ValueError(f"Unsafe table_alias: {table_alias!r}")
if rank_alias is not None and not _SAFE_ALIAS_RE.fullmatch(rank_alias):
raise ValueError(f"Unsafe rank_alias: {rank_alias!r}")
key = _normalize_sort_key(sort)
if key == "name_asc":
return f"ORDER BY LOWER({table_alias}.filename) ASC, {table_alias}.id DESC"
@@ -842,6 +849,7 @@ class IndexSearcher:
# _BROWSE_COUNT_TTL seconds.
self._browse_count_cache: dict[str, tuple[float, int]] = {}
self._BROWSE_COUNT_TTL = 5.0 # seconds
self._BROWSE_COUNT_CACHE_MAX = 128
async def ensure_vocab(self):
"""Ensure FTS5 vocab table exists for autocomplete."""
@@ -1023,6 +1031,13 @@ class IndexSearcher:
count_result = await self.db.aquery(count_sql, tuple(count_params))
total = count_result.data[0]["total"] if count_result.ok and count_result.data else 0
self._browse_count_cache[cache_key] = (now, total)
# Evict oldest entries when the cache grows beyond the bound.
if len(self._browse_count_cache) > self._BROWSE_COUNT_CACHE_MAX:
try:
oldest_key = min(self._browse_count_cache, key=lambda k: self._browse_count_cache[k][0])
del self._browse_count_cache[oldest_key]
except Exception: # pragma: no cover
pass
return Result.Ok({"rows": rows, "total": total})
async def _search_global_fts_rows(
+30
View File
@@ -161,6 +161,8 @@ class DebouncedWatchHandler(FileSystemEventHandler):
concurrency = max(1, flush_concurrency or 1)
self._flush_semaphore = asyncio.Semaphore(concurrency)
self._last_pending_warning = 0.0
self._last_overflow_drop_warning = 0.0
self._dropped_count = 0
self._refresh_runtime_settings()
def _is_ignored_path(self, path: str) -> bool:
@@ -246,6 +248,22 @@ class DebouncedWatchHandler(FileSystemEventHandler):
pass
self._last_pending_warning = now
def _maybe_log_overflow_drop(self, path: str) -> None:
now = time.time()
if (now - self._last_overflow_drop_warning) < _PENDING_LIMIT_WARN_INTERVAL:
return
try:
logger.warning(
"Watcher overflow queue full (%d); file dropped: %s (total dropped: %d). "
"Files will be picked up on next scan.",
_MAX_PENDING_FILES,
path,
self._dropped_count,
)
except Exception:
pass
self._last_overflow_drop_warning = now
def on_created(self, event):
if isinstance(event, DirCreatedEvent):
return
@@ -301,6 +319,9 @@ class DebouncedWatchHandler(FileSystemEventHandler):
# pending stays full and events arrive faster than they are flushed.
if key not in self._overflow and len(self._overflow) < _MAX_PENDING_FILES:
self._overflow[key] = now
elif key not in self._overflow:
self._dropped_count += 1
self._maybe_log_overflow_drop(path)
self._maybe_log_pending_limit(path)
self._schedule_flush()
return
@@ -440,10 +461,19 @@ class DebouncedWatchHandler(FileSystemEventHandler):
def _requeue_failed_files(self, files: list[str]) -> None:
"""Re-queue files that the index callback failed to process into overflow."""
now = time.time()
dropped = 0
with self._lock:
for f in files:
if _MAX_PENDING_FILES <= 0 or len(self._overflow) < _MAX_PENDING_FILES:
self._overflow[f] = now
else:
dropped += 1
if dropped:
logger.warning(
"Watcher: %d failed files could not be re-queued (overflow full at %d)",
dropped,
_MAX_PENDING_FILES,
)
def _requeue_runtime_deferred_files(self, files: list[str]) -> None:
now = time.time()
+5 -1
View File
@@ -1107,7 +1107,7 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
(row_job_id, asset_id),
)
restack_job_ids.add(row_job_id)
# Propagate job_id to base video/audio files from suffixed siblings (e.g. -audio.mp4)
for row in sibling_rows:
filename_value = row.get("filename") or ""
@@ -1186,6 +1186,10 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
except Exception as ex:
logger.debug("Failed to assign node provenance: %s", ex)
if restack_job_ids:
await _finalize_indexed_execution_stacks(svc, restack_job_ids)
restack_job_ids.clear()
if gen_time_by_id:
try:
payloads_by_id: dict[int, dict[str, Any]] = {}
+2 -2
View File
@@ -585,6 +585,6 @@ NODE_CLASS_MAPPINGS: dict[str, type] = {
}
NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {
"MajoorSaveImage": "Majoor Save Image 💾",
"MajoorSaveVideo": "Majoor Save Video 🎬",
"MajoorSaveImage": "〽️ Majoor Save Image 💾",
"MajoorSaveVideo": "〽️ Majoor Save Video 🎬",
}
+355
View File
@@ -0,0 +1,355 @@
from mjr_am_backend.features.geninfo import api_node_extractor as x
from mjr_am_backend.features.geninfo import parser_impl as p
# ---------------------------------------------------------------------------
# Detection helpers
# ---------------------------------------------------------------------------
def test_detect_api_node_known_providers():
assert x._detect_api_node("geminiimagenode") == "google_gemini"
assert x._detect_api_node("openaiimagenode") == "openai"
assert x._detect_api_node("claudenode") == "anthropic"
assert x._detect_api_node("bytedanceseedancenode") == "bytedance_seedance"
assert x._detect_api_node("klingtexttovideo") == "kling_ai"
assert x._detect_api_node("lumaimagetovideo") == "luma_dream_machine"
assert x._detect_api_node("minimaxhailuo") == "minimax_hailuo"
assert x._detect_api_node("runwayimagetovideo") == "runway"
assert x._detect_api_node("vidutexttovideo") == "vidu"
assert x._detect_api_node("moonvalleytxt2video") == "moonvalley"
assert x._detect_api_node("pixversetexttovideo") == "pixverse"
assert x._detect_api_node("ideogramv2") == "ideogram"
assert x._detect_api_node("recraftv4") == "recraft"
assert x._detect_api_node("grokimage") == "xai_grok"
assert x._detect_api_node("ltxvapitext") == "ltxv_api"
assert x._detect_api_node("elevenlabstextto") == "eleven_labs"
assert x._detect_api_node("wan2texttovideoapi") == "alibaba_wan"
assert x._detect_api_node("fluxkontext") == "black_forest_labs"
assert x._detect_api_node("stabilitystableimage") == "stability_ai"
assert x._detect_api_node("unknownnode") is None
def test_detect_api_node_specificity_order():
# More specific fragments must match before the broad catch-all
assert x._detect_api_node("bytedanceseedream") == "bytedance_seedance"
assert x._detect_api_node("bytedance2referencenode") == "bytedance_seedance"
assert x._detect_api_node("geminiimage2node") == "google_gemini"
assert x._detect_api_node("veo3videogeneration") == "google_veo"
def test_provider_from_model_name():
assert x._provider_from_model_name("seedance-2.0-pro") == "bytedance_seedance"
assert x._provider_from_model_name("gemini-2.0-flash") == "google_gemini"
assert x._provider_from_model_name("veo-3") == "google_veo"
assert x._provider_from_model_name("gpt-4o") == "openai"
assert x._provider_from_model_name("dall-e-3") == "openai"
assert x._provider_from_model_name("claude-3-opus") == "anthropic"
assert x._provider_from_model_name("flux-kontext-pro") == "black_forest_labs"
assert x._provider_from_model_name("stable-diffusion-xl") == "stability_ai"
assert x._provider_from_model_name("wan-2.1") == "alibaba_wan"
assert x._provider_from_model_name("kling-v2") == "kling_ai"
assert x._provider_from_model_name("luma-dream-machine") == "luma_dream_machine"
assert x._provider_from_model_name("minimax-01") == "minimax_hailuo"
assert x._provider_from_model_name("runway-gen3") == "runway"
assert x._provider_from_model_name("vidu-q1") == "vidu"
assert x._provider_from_model_name("ideogram-v3") == "ideogram"
assert x._provider_from_model_name("recraft-v3") == "recraft"
assert x._provider_from_model_name("grok-aurora") == "xai_grok"
assert x._provider_from_model_name("pixverse-v4") == "pixverse"
assert x._provider_from_model_name("unknown-model-xyz") is None
def test_provider_from_model_name_wan_no_media_type_required():
# Wan provider must be detected even without "video" or "image" in name
assert x._provider_from_model_name("wan-2.1-14b") == "alibaba_wan"
assert x._provider_from_model_name("wanx-turbo") == "alibaba_wan"
# ---------------------------------------------------------------------------
# Node classification
# ---------------------------------------------------------------------------
def test_is_api_image_node_known_class_type():
node = {"class_type": "GeminiImageNode", "inputs": {}}
assert x._is_api_image_node(node) is True
def test_is_api_image_node_heuristic_fallback():
node = {
"class_type": "CustomExternalGen",
"inputs": {"prompt": "a cat", "model": "custom-v1"},
}
assert x._is_api_image_node(node) is True
def test_is_api_image_node_heuristic_rejects_sampler_node():
node = {
"class_type": "CustomSampler",
"inputs": {"prompt": "a cat", "model": "v1", "cfg": 7.5, "steps": 20},
}
assert x._is_api_image_node(node) is False
def test_is_api_image_node_rejects_non_api():
node = {"class_type": "CLIPTextEncode", "inputs": {"text": "hello"}}
assert x._is_api_image_node(node) is False
# ---------------------------------------------------------------------------
# Field extraction helpers
# ---------------------------------------------------------------------------
def test_extract_api_node_prompt_priority():
assert x._extract_api_node_prompt({"prompt": "a cat"}) == "a cat"
assert x._extract_api_node_prompt({"text": "a dog"}) == "a dog"
assert x._extract_api_node_prompt({"positive_prompt": "sun"}) == "sun"
assert x._extract_api_node_prompt({}) is None
assert x._extract_api_node_prompt({"prompt": " "}) is None
def test_extract_api_node_negative():
assert x._extract_api_node_negative({"negative_prompt": "blur"}) == "blur"
assert x._extract_api_node_negative({"negative": "ugly"}) == "ugly"
assert x._extract_api_node_negative({}) is None
def test_extract_api_node_model():
node = {"class_type": "GeminiImageNode"}
m = x._extract_api_node_model({"model": "gemini-2.0-flash"}, "1", node)
assert m is not None
assert m["name"] == "gemini-2.0-flash"
assert m["confidence"] == "high"
assert x._extract_api_node_model({}, "1", node) is None
assert x._extract_api_node_model({"model": " "}, "1", node) is None
def test_extract_api_system_prompt():
assert x._extract_api_system_prompt({"system_prompt": "be concise"}) == "be concise"
assert x._extract_api_system_prompt({"system": "you are helpful"}) == "you are helpful"
assert x._extract_api_system_prompt({}) is None
# ---------------------------------------------------------------------------
# Engine type refinement
# ---------------------------------------------------------------------------
def test_refine_engine_type_txt2img():
out = {"engine": {"type": "txt2img"}}
x._refine_engine_type(out, "openaitexttoimageapi", {})
assert out["engine"]["type"] == "txt2img"
def test_refine_engine_type_img2vid():
out = {"engine": {"type": "txt2vid"}}
x._refine_engine_type(out, "klingimagetovideo", {})
assert out["engine"]["type"] == "img2vid"
def test_refine_engine_type_img2vid_from_video_with_image_input():
out = {"engine": {"type": "txt2vid"}}
x._refine_engine_type(out, "somevideogen", {"image": ["1", 0]})
assert out["engine"]["type"] == "img2vid"
def test_refine_engine_type_txt2vid_from_video_no_image_input():
out = {"engine": {"type": "txt2img"}}
x._refine_engine_type(out, "somevideogen", {"prompt": "a sunset"})
assert out["engine"]["type"] == "txt2vid"
def test_refine_engine_type_audio():
out = {"engine": {"type": "txt2img"}}
x._refine_engine_type(out, "sonilotexttomusic", {})
assert out["engine"]["type"] == "audio"
# ---------------------------------------------------------------------------
# Seedance helpers
# ---------------------------------------------------------------------------
def test_seedance_size_from_resolution():
assert x._seedance_size_from_resolution("720p") == (1280, 720)
assert x._seedance_size_from_resolution("1080p") == (1920, 1080)
assert x._seedance_size_from_resolution("480p") == (854, 480)
assert x._seedance_size_from_resolution("4k") == (3840, 2160)
assert x._seedance_size_from_resolution("360p") is None
def test_seedance_media_type_txt2vid():
assert x._seedance_media_type({"prompt": "a cat"}) == "txt2vid"
def test_seedance_media_type_img2vid():
ins = {"model.reference_images": ["1", 0]}
assert x._seedance_media_type(ins) == "img2vid"
# ---------------------------------------------------------------------------
# Full fallback entry-point
# ---------------------------------------------------------------------------
def _make_gemini_nodes(prompt="a golden retriever in sunlight", model="gemini-2.0-flash-preview-image-generation"):
return {
"1": {
"class_type": "GeminiImageNode",
"inputs": {"prompt": prompt, "model": model, "seed": 42},
},
"2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}},
}
def test_extract_api_node_geninfo_fallback_basic():
nodes = _make_gemini_nodes()
result = x._extract_api_node_geninfo_fallback(nodes, None)
assert result is not None
assert result["positive"]["value"] == "a golden retriever in sunlight"
assert result["engine"]["api_provider"] == "google_gemini"
assert result["engine"]["sampler_mode"] == "api"
assert result["checkpoint"]["name"] == "gemini-2.0-flash-preview-image-generation"
assert result["seed"]["value"] == 42
def test_extract_api_node_geninfo_fallback_no_api_nodes():
nodes = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "hello"}},
}
assert x._extract_api_node_geninfo_fallback(nodes, None) is None
def test_extract_api_node_geninfo_fallback_returns_none_if_no_meaningful_fields():
# Node is detected as API type but has no extractable data
nodes = {"1": {"class_type": "GeminiImageNode", "inputs": {}}}
result = x._extract_api_node_geninfo_fallback(nodes, None)
assert result is None
def test_extract_api_node_geninfo_fallback_with_workflow_meta():
nodes = _make_gemini_nodes()
meta = {"title": "Test Workflow"}
result = x._extract_api_node_geninfo_fallback(nodes, meta)
assert result is not None
assert result.get("metadata") == meta
def test_extract_api_node_geninfo_fallback_multiple_nodes():
nodes = {
"1": {
"class_type": "GeminiImageNode",
"inputs": {"prompt": "first prompt", "model": "gemini-2.0"},
},
"2": {
"class_type": "OpenAIImageNode",
"inputs": {"prompt": "second prompt", "model": "dall-e-3"},
},
"3": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}},
}
result = x._extract_api_node_geninfo_fallback(nodes, None)
assert result is not None
extra = result.get("extra_api_passes", [])
assert len(extra) == 1
assert extra[0]["positive"]["value"] == "second prompt"
def test_extract_api_node_geninfo_fallback_openai():
nodes = {
"1": {
"class_type": "OpenAIImageNode",
"inputs": {"prompt": "a serene mountain lake", "model": "gpt-image-1", "seed": 7},
},
}
result = x._extract_api_node_geninfo_fallback(nodes, None)
assert result is not None
assert result["engine"]["api_provider"] == "openai"
assert result["positive"]["value"] == "a serene mountain lake"
def test_extract_api_node_geninfo_fallback_seedance_dotted_keys():
nodes = {
"1": {
"class_type": "ByteDanceSeedanceNode",
"inputs": {
"model": "Seedance 2.0",
"model.prompt": "cinematic ocean sunset",
"model.negative_prompt": "blur, noisy",
"model.resolution": "1080p",
"model.duration": 5,
"seed": 99,
},
},
}
result = x._extract_api_node_geninfo_fallback(nodes, None)
assert result is not None
assert result["engine"]["api_provider"] == "bytedance_seedance"
assert result["positive"]["value"] == "cinematic ocean sunset"
assert result["negative"]["value"] == "blur, noisy"
assert result["size"]["width"] == 1920
assert result["seed"]["value"] == 99
def test_extract_api_node_geninfo_fallback_with_negative_prompt():
nodes = {
"1": {
"class_type": "StabilityStableImage",
"inputs": {
"prompt": "portrait of a warrior",
"negative_prompt": "ugly, deformed",
"model": "stable-diffusion-xl-1024-v1-0",
},
},
}
result = x._extract_api_node_geninfo_fallback(nodes, None)
assert result is not None
assert result["negative"]["value"] == "ugly, deformed"
def test_extract_api_node_geninfo_fallback_size_from_width_height():
nodes = {
"1": {
"class_type": "RecraftV4",
"inputs": {"prompt": "abstract art", "model": "recraft-v3", "width": 1024, "height": 768},
},
}
result = x._extract_api_node_geninfo_fallback(nodes, None)
assert result is not None
assert result["size"]["width"] == 1024
assert result["size"]["height"] == 768
def test_extract_api_node_geninfo_fallback_wan_provider():
nodes = {
"1": {
"class_type": "Wan2TextToVideoAPI",
"inputs": {"prompt": "a butterfly emerging from cocoon", "model": "wan-2.1"},
},
}
result = x._extract_api_node_geninfo_fallback(nodes, None)
assert result is not None
assert result["engine"]["api_provider"] == "alibaba_wan"
def test_extract_api_node_geninfo_fallback_aspect_ratio():
nodes = {
"1": {
"class_type": "KlingTextToVideo",
"inputs": {"prompt": "flying eagle", "model": "kling-v2", "aspect_ratio": "16:9"},
},
}
result = x._extract_api_node_geninfo_fallback(nodes, None)
assert result is not None
assert result.get("aspect_ratio", {}).get("value") == "16:9"
def test_extract_api_node_geninfo_fallback_full_integration_via_parser():
"""End-to-end: parse_geninfo_from_prompt must surface API node data when no sampler present."""
nodes = {
"1": {
"class_type": "GeminiImageNode",
"inputs": {"prompt": "a red fox in a snowy forest", "model": "gemini-2.0-flash", "seed": 12},
},
"2": {"class_type": "SaveImage", "inputs": {"images": ["1", 0]}},
}
res = p.parse_geninfo_from_prompt(nodes)
assert res.ok
data = res.data
assert isinstance(data, dict)
assert data.get("positive", {}).get("value") == "a red fox in a snowy forest"
assert data.get("engine", {}).get("api_provider") == "google_gemini"
+112 -1
View File
@@ -278,6 +278,117 @@ def test_resolve_scalar_from_string_replace():
assert p._resolve_scalar_from_link(nodes, ["2", 0]) == "cinematic portrait of a woman"
def test_ernie_image_turbo_real_graph_extracts_original_prompt():
"""Real Ernie Image Turbo subgraph: TextGenerate enhances the user prompt
via a chain of StringReplace nodes feeding a system-prompt template.
The parser must extract the *original* user prompt, not the LLM-enhanced text."""
user_prompt = "A stylized cinematic portrait of a woman at twilight"
nodes = {
"88:93": {
"class_type": "StringReplace",
"inputs": {
"string": '<s>[SYSTEM_PROMPT]You are a prompt enhancer.[/SYSTEM_PROMPT][INST]{{"prompt": "{prompt}", "width": {width}, "height": {height}}}[/INST]',
"find": "{prompt}",
"replace": ["88:94", 0],
},
},
"88:94": {
"class_type": "PrimitiveStringMultiline",
"inputs": {"value": user_prompt},
},
"88:99": {
"class_type": "PreviewAny",
"inputs": {"preview_text": "1024", "source": 1024},
},
"88:100": {
"class_type": "PreviewAny",
"inputs": {"preview_text": "1024", "source": 1024},
},
"88:101": {
"class_type": "StringReplace",
"inputs": {"string": ["88:93", 0], "find": "{width}", "replace": ["88:99", 0]},
},
"88:102": {
"class_type": "StringReplace",
"inputs": {"string": ["88:101", 0], "find": "{height}", "replace": ["88:100", 0]},
},
"88:95": {
"class_type": "TextGenerate",
"inputs": {"prompt": ["88:102", 0], "max_length": 2048, "clip": ["88:98", 0]},
},
"88:96": {"class_type": "PrimitiveBoolean", "inputs": {"value": True}},
"88:97": {
"class_type": "ComfySwitchNode",
"inputs": {"switch": ["88:96", 0], "on_false": ["88:94", 0], "on_true": ["88:95", 0]},
},
"88:103": {
"class_type": "PreviewAny",
"inputs": {
"source": ["88:95", 0],
"preview_text": "Enhanced Chinese LLM description of the image",
},
},
"88:67": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["88:97", 0], "clip": ["88:62", 0]},
},
"88:62": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": "ministral-3-3b.safetensors", "type": "flux2"},
},
"88:91": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["88:67", 0]},
},
"88:66": {
"class_type": "UNETLoader",
"inputs": {"unet_name": "ernie-image-turbo.safetensors"},
},
"88:63": {"class_type": "VAELoader", "inputs": {"vae_name": "flux2-vae.safetensors"}},
"88:71": {
"class_type": "EmptyFlux2LatentImage",
"inputs": {"width": 1024, "height": 1024, "batch_size": 1},
},
"88:70": {
"class_type": "KSampler",
"inputs": {
"seed": 42,
"steps": 8,
"cfg": 1.0,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1.0,
"model": ["88:66", 0],
"positive": ["88:67", 0],
"negative": ["88:91", 0],
"latent_image": ["88:71", 0],
},
},
"88:65": {
"class_type": "VAEDecode",
"inputs": {"samples": ["88:70", 0], "vae": ["88:63", 0]},
},
"88:98": {
"class_type": "CLIPLoader",
"inputs": {"clip_name": "ernie-image-prompt-enhancer.safetensors", "type": "flux2"},
},
"104": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "output", "images": ["88:65", 0]},
},
}
res = p.parse_geninfo_from_prompt(nodes)
assert res.ok
data = res.data
assert isinstance(data, dict)
pos = data.get("positive", {})
assert pos.get("value") == user_prompt
# Negative is ConditioningZeroOut — no text expected.
neg = data.get("negative", {})
assert not neg.get("value")
def test_prompt_extracted_through_ernie_switch_and_preview_any_output():
nodes = {
"1": {"class_type": "PrimitiveStringMultiline", "inputs": {"value": "portrait of a woman at dusk"}},
@@ -328,7 +439,7 @@ def test_prompt_extracted_through_ernie_switch_and_preview_any_output():
data = res.data
assert isinstance(data, dict)
pos = data.get("positive", {})
assert pos.get("value") == "A cinematic side-profile portrait of a woman at dusk with dramatic neon rim lighting."
assert pos.get("value") == "portrait of a woman at dusk"
def test_parse_geninfo_supports_pysssss_string_function_append():