380 lines
16 KiB
JavaScript
380 lines
16 KiB
JavaScript
/**
|
||
* Entry Point - Majoor Assets Manager
|
||
* Registers extension with ComfyUI and mounts UI.
|
||
*/
|
||
|
||
import { app } from "../../scripts/app.js";
|
||
import { testAPI, triggerStartupScan } from "./app/bootstrap.js";
|
||
import { ensureStyleLoaded } from "./app/style.js";
|
||
import { registerMajoorSettings } from "./app/settings.js";
|
||
import { initDragDrop } from "./features/dnd/DragDrop.js";
|
||
import { loadAssets, upsertAsset, captureAnchor, restoreAnchor, isAtBottom, scrollToBottom } from "./features/grid/GridView.js";
|
||
import { renderAssetsManager, getActiveGridContainer } from "./features/panel/AssetsManagerPanel.js";
|
||
import { extractOutputFiles } from "./utils/extractOutputFiles.js";
|
||
import { post, get } from "./api/client.js";
|
||
import { ENDPOINTS } from "./api/endpoints.js";
|
||
import { buildListURL } from "./api/endpoints.js";
|
||
|
||
const UI_FLAGS = {
|
||
useComfyThemeUI: true,
|
||
};
|
||
|
||
const ENTRY_RUNTIME_KEY = "__MJR_AM_ENTRY_RUNTIME__";
|
||
const UI_BOOT_DELAY_MS = 1500;
|
||
const RAF_STABILIZE_FRAMES = 2;
|
||
const AUTO_REFRESH_DEBOUNCE_MS = 500;
|
||
const AUTO_INSERT_FETCH_LIMIT = 50;
|
||
|
||
const _raf = () =>
|
||
new Promise((resolve) => {
|
||
try {
|
||
requestAnimationFrame(() => resolve(true));
|
||
} catch {
|
||
resolve(false);
|
||
}
|
||
});
|
||
|
||
function isObservabilityEnabled() {
|
||
try {
|
||
const raw = localStorage?.getItem?.("mjrSettings");
|
||
if (!raw) return false;
|
||
const parsed = JSON.parse(raw);
|
||
return !!parsed?.observability?.enabled;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "Majoor.AssetsManager",
|
||
|
||
async setup() {
|
||
// Best-effort hot-reload safety: abort previous listeners/timers for this extension instance.
|
||
const runtime = (() => {
|
||
try {
|
||
const w = typeof window !== "undefined" ? window : null;
|
||
const prev = w?.[ENTRY_RUNTIME_KEY] || null;
|
||
try {
|
||
prev?.abort?.();
|
||
} catch {}
|
||
|
||
const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
|
||
const timers = new Set();
|
||
const trackTimeout = (fn, ms) => {
|
||
try {
|
||
const id = setTimeout(fn, ms);
|
||
timers.add(id);
|
||
return id;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
const clearTimers = () => {
|
||
try {
|
||
for (const t of Array.from(timers)) {
|
||
try {
|
||
clearTimeout(t);
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
try {
|
||
timers.clear();
|
||
} catch {}
|
||
};
|
||
try {
|
||
ac?.signal?.addEventListener?.("abort", clearTimers, { once: true });
|
||
} catch {}
|
||
|
||
const delay = (ms) =>
|
||
new Promise((resolve) => {
|
||
try {
|
||
if (ac?.signal?.aborted) return resolve(false);
|
||
} catch {}
|
||
const id = trackTimeout(() => resolve(true), ms);
|
||
try {
|
||
ac?.signal?.addEventListener?.(
|
||
"abort",
|
||
() => {
|
||
try {
|
||
if (id != null) clearTimeout(id);
|
||
} catch {}
|
||
resolve(false);
|
||
},
|
||
{ once: true }
|
||
);
|
||
} catch {}
|
||
});
|
||
|
||
const cleanupCbs = [];
|
||
|
||
const rt = {
|
||
ac,
|
||
trackTimeout,
|
||
delay,
|
||
register: (fn) => { if (typeof fn === "function") cleanupCbs.push(fn); },
|
||
abort: () => {
|
||
try {
|
||
clearTimers();
|
||
} catch {}
|
||
try {
|
||
ac?.abort?.();
|
||
} catch {}
|
||
try {
|
||
cleanupCbs.forEach(fn => { try { fn(); } catch {} });
|
||
} catch {}
|
||
},
|
||
};
|
||
|
||
try {
|
||
if (w) w[ENTRY_RUNTIME_KEY] = rt;
|
||
} catch {}
|
||
return rt;
|
||
} catch {
|
||
return { ac: null, trackTimeout: setTimeout, delay: (ms) => new Promise((r) => setTimeout(() => r(true), ms)), abort: () => {} };
|
||
}
|
||
})();
|
||
|
||
testAPI();
|
||
ensureStyleLoaded({ enabled: UI_FLAGS.useComfyThemeUI });
|
||
|
||
try {
|
||
const disposeDnD = initDragDrop();
|
||
runtime.register(disposeDnD);
|
||
} catch {}
|
||
|
||
registerMajoorSettings(app, () => {
|
||
const grid = getActiveGridContainer();
|
||
if (grid) loadAssets(grid);
|
||
});
|
||
|
||
triggerStartupScan();
|
||
|
||
const api = app.api || (app.ui ? app.ui.api : null);
|
||
if (api) {
|
||
// [ISSUE 1] Backend Scan Notification -> UI Event
|
||
try {
|
||
api.addEventListener("mjr-scan-complete", (e) => {
|
||
const detail = e.detail;
|
||
window.dispatchEvent(new CustomEvent('mjr:scan-complete', { detail }));
|
||
});
|
||
} catch (err) {
|
||
console.warn("[Majoor] Failed to bind scan listener", err);
|
||
}
|
||
|
||
// Avoid double-binding when ComfyUI reloads extensions.
|
||
try {
|
||
if (api._mjrRtHandlers && typeof api._mjrRtHandlers === "object") {
|
||
for (const [evt, handler] of Object.entries(api._mjrRtHandlers)) {
|
||
try {
|
||
api.removeEventListener(String(evt), handler);
|
||
} catch {}
|
||
}
|
||
} else if (api._mjrExecutedHandler) {
|
||
api.removeEventListener("executed", api._mjrExecutedHandler);
|
||
}
|
||
} catch {}
|
||
|
||
// Batch state for debouncing multiple checks
|
||
api._mjrPendingFiles = api._mjrPendingFiles || [];
|
||
api._mjrProcessingQueue = false; // Mutex for async processing
|
||
api._mjrExecutionStarts = api._mjrExecutionStarts || new Map();
|
||
|
||
// Listen for execution start to effectively measure generation time
|
||
const onExecutionStart = (event) => {
|
||
const promptId = event?.detail?.prompt_id;
|
||
if (promptId) {
|
||
api._mjrExecutionStarts.set(promptId, Date.now());
|
||
}
|
||
};
|
||
api.addEventListener("execution_start", onExecutionStart);
|
||
|
||
// Async queue processor that mimics "Image Feed" reactivity but ensures DB consistency
|
||
const processFileQueue = async () => {
|
||
if (api._mjrProcessingQueue) return; // Already working
|
||
if (api._mjrPendingFiles.length === 0) return;
|
||
|
||
api._mjrProcessingQueue = true;
|
||
|
||
// Grab current batch
|
||
const batch = [...api._mjrPendingFiles];
|
||
api._mjrPendingFiles = [];
|
||
|
||
try {
|
||
// 1. Index the files (backend)
|
||
// console.log(`📂 Majoor [ℹ️] Indexing batch of ${batch.length} files...`);
|
||
const indexRes = await post(ENDPOINTS.INDEX_FILES, { files: batch, incremental: true });
|
||
|
||
if (!indexRes?.ok) {
|
||
console.warn("📂 Majoor [⚠️] index-files failed:", indexRes?.error || indexRes);
|
||
}
|
||
|
||
// 2. Fetch latest & visual update
|
||
// Only update if the user is looking at the default view (no search query)
|
||
const latestGrid = getActiveGridContainer();
|
||
const currentQuery = String(latestGrid?.dataset?.mjrQuery || "*").trim() || "*";
|
||
|
||
if (latestGrid && (currentQuery === "*" || currentQuery === "")) {
|
||
// Use incremental update logic
|
||
const anchor = captureAnchor(latestGrid);
|
||
const atBottomBefore = isAtBottom(latestGrid);
|
||
|
||
// Read filter state from DOM
|
||
const scope = latestGrid.dataset?.mjrScope || "output";
|
||
const customRootId = latestGrid.dataset?.mjrCustomRootId || "";
|
||
const subfolder = latestGrid.dataset?.mjrSubfolder || "";
|
||
const kind = latestGrid.dataset?.mjrFilterKind || "";
|
||
const workflowOnly = latestGrid.dataset?.mjrFilterWorkflowOnly === "1";
|
||
const minRating = Number(latestGrid.dataset?.mjrFilterMinRating || 0) || 0;
|
||
const dateRange = String(latestGrid.dataset?.mjrFilterDateRange || "").trim().toLowerCase();
|
||
const dateExact = String(latestGrid.dataset?.mjrFilterDateExact || "").trim();
|
||
|
||
// Fetch the newest page (Limit increased to catch batch bursts)
|
||
// This "rescans from 0" (offset 0) to get the latest
|
||
const url = buildListURL({
|
||
q: "*",
|
||
limit: Math.max(AUTO_INSERT_FETCH_LIMIT, batch.length * 2), // Ensure we fetch enough to cover the batch
|
||
offset: 0,
|
||
scope,
|
||
subfolder,
|
||
customRootId: customRootId || null,
|
||
kind: kind || null,
|
||
hasWorkflow: workflowOnly ? true : null,
|
||
minRating: minRating > 0 ? minRating : null,
|
||
dateRange: dateRange || null,
|
||
dateExact: dateExact || null,
|
||
includeTotal: false
|
||
});
|
||
|
||
const result = await get(url);
|
||
if (result?.ok && Array.isArray(result.data?.assets)) {
|
||
for (const asset of result.data.assets) {
|
||
try {
|
||
upsertAsset(latestGrid, asset);
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
// Auto-scroll logic
|
||
if (atBottomBefore) {
|
||
for (let i = 0; i < RAF_STABILIZE_FRAMES; i++) await _raf();
|
||
scrollToBottom(latestGrid);
|
||
} else {
|
||
await restoreAnchor(latestGrid, anchor);
|
||
}
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error("📂 Majoor [❌] Queue processing error:", err);
|
||
} finally {
|
||
api._mjrProcessingQueue = false;
|
||
|
||
// If new files arrived while we were working, go again immediately
|
||
if (api._mjrPendingFiles.length > 0) {
|
||
// Small yield to UI thread before looping
|
||
setTimeout(processFileQueue, 16);
|
||
}
|
||
}
|
||
};
|
||
|
||
api._mjrExecutedHandler = async (event) => {
|
||
try {
|
||
if (runtime?.ac?.signal?.aborted) return;
|
||
} catch {}
|
||
|
||
const detail = event?.detail;
|
||
const output = detail?.output || detail || null;
|
||
const promptId = detail?.prompt_id;
|
||
|
||
const grid = getActiveGridContainer();
|
||
if (!output) return; // Even if grid is hidden, we might want to buffer? No, save resources.
|
||
|
||
// Note: Image Feed logic doesn't require grid, but we need it for `upsertAsset`.
|
||
// If grid is not present, we still index? Yes, useful for consistency.
|
||
// But `processFileQueue` checks for grid before fetching.
|
||
|
||
const hasMedia = output.images || output.gifs || output.videos;
|
||
if (!hasMedia) return;
|
||
|
||
const newFiles = extractOutputFiles(output);
|
||
if (!newFiles.length) return;
|
||
|
||
// Calculate generation time if we saw the start
|
||
let genTimeMs = undefined;
|
||
if (promptId && api._mjrExecutionStarts.has(promptId)) {
|
||
const start = api._mjrExecutionStarts.get(promptId);
|
||
genTimeMs = Date.now() - start;
|
||
api._mjrExecutionStarts.delete(promptId); // Cleanup
|
||
}
|
||
|
||
if (genTimeMs) {
|
||
// Attach duration to each file object so backend can index it
|
||
for (const f of newFiles) {
|
||
f.generation_time_ms = genTimeMs;
|
||
}
|
||
}
|
||
|
||
// Add to batch
|
||
api._mjrPendingFiles.push(...newFiles);
|
||
|
||
// Trigger processing (Debounce removed: Queue Pattern used instead)
|
||
processFileQueue();
|
||
};
|
||
|
||
// Bind to multiple event names across ComfyUI variants.
|
||
const rtHandlers = {};
|
||
const bind = (evt) => {
|
||
try {
|
||
api.addEventListener(evt, api._mjrExecutedHandler, runtime?.ac ? { signal: runtime.ac.signal } : undefined);
|
||
rtHandlers[evt] = api._mjrExecutedHandler;
|
||
} catch {}
|
||
};
|
||
for (const evt of ["executed", "execution_success", "execution_complete"]) bind(evt);
|
||
api._mjrRtHandlers = rtHandlers;
|
||
try {
|
||
runtime?.ac?.signal?.addEventListener?.(
|
||
"abort",
|
||
() => {
|
||
try {
|
||
if (api._mjrRtHandlers && typeof api._mjrRtHandlers === "object") {
|
||
for (const [evt, handler] of Object.entries(api._mjrRtHandlers)) {
|
||
try {
|
||
api.removeEventListener(String(evt), handler);
|
||
} catch {}
|
||
}
|
||
}
|
||
} catch {}
|
||
try {
|
||
api._mjrRtHandlers = {};
|
||
} catch {}
|
||
try {
|
||
api._mjrExecutedHandler = null;
|
||
} catch {}
|
||
},
|
||
{ once: true }
|
||
);
|
||
} catch {}
|
||
|
||
console.log("📂 Majoor [✅] Real-time listener registered");
|
||
} else {
|
||
console.warn("📂 Majoor [⚠️] API not available, real-time updates disabled");
|
||
}
|
||
|
||
if (app.extensionManager?.registerSidebarTab) {
|
||
app.extensionManager.registerSidebarTab({
|
||
id: "majoor-assets",
|
||
icon: "pi pi-folder",
|
||
title: "Assets Manager",
|
||
tooltip: "Majoor Assets Manager - Browse and search your outputs",
|
||
type: "custom",
|
||
render: (el) => {
|
||
void renderAssetsManager(el, { useComfyThemeUI: UI_FLAGS.useComfyThemeUI });
|
||
},
|
||
});
|
||
|
||
console.log("📂 Majoor Assets Manager: Sidebar tab registered");
|
||
} else {
|
||
console.warn("📂 Majoor Assets Manager: extensionManager.registerSidebarTab is unavailable");
|
||
}
|
||
},
|
||
});
|