Merge remote-tracking branch 'upstream/main' into feat/bulk-tags

# Conflicts:
#	dist/chunks/FloatingViewer--x61GAzy.js
#	dist/chunks/FloatingViewer-BiEeZ0CS.js
#	dist/chunks/FloatingViewer-D8bHMZQk.js
#	dist/chunks/LiveStreamTracker-B56NDtCP.js
#	dist/chunks/LiveStreamTracker-CIt2lOUV.js
#	dist/chunks/LiveStreamTracker-C_DbXPzd.js
#	dist/chunks/SidebarWorkflowSection-DrCNoS04.js
#	dist/chunks/TagsEditor-2AmCKykg.js
#	dist/chunks/Viewer-W-G40j3K.js
#	dist/chunks/ViewerPortal-gWBXDS-v.js
#	dist/chunks/config-C9a-wT5M.js
#	dist/chunks/events-C65wdOHm.js
#	dist/chunks/events-tsQFsZrH.js
#	dist/chunks/floatingViewerManager-BzaQii21.js
#	dist/chunks/genInfo-DD23-sz2.js
#	dist/chunks/model3dRenderer-BnFOXFbn.js
#	dist/chunks/openMajoorSettings-2yBqYLc0.js
#	dist/chunks/openMajoorSettings-IdB9HDLb.js
#	dist/chunks/sideBySide-Dahw1GfU.js
#	dist/chunks/tooltipShortcuts-Ury2eTek.js
#	dist/chunks/viewerOpenRequest-WCeGPIlE.js
#	dist/chunks/viewerRuntimeHosts-n0pKLECw.js
#	dist/entry.js
This commit is contained in:
Wiley Walarus
2026-11-04 12:37:11 -08:00
315 changed files with 39090 additions and 62888 deletions
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.14"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ matrix.python-version }}
- name: Install deps
@@ -41,7 +41,7 @@ jobs:
python-version: ["3.10", "3.12"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ matrix.python-version }}
- name: Install deps
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
- name: Set up Node.js
@@ -71,7 +71,7 @@ jobs:
- name: Check out code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
- name: Set up Node.js
+7 -4
View File
@@ -29,7 +29,7 @@ jobs:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
@@ -96,6 +96,9 @@ jobs:
- name: Typecheck frontend
run: npx tsc -p tsconfig.json --noEmit --pretty false
- name: Typecheck frontend tests
run: npx tsc -p tsconfig.test.json --noEmit --pretty false
- name: Build frontend bundle
run: npm run build
@@ -119,7 +122,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- name: Tune Git HTTP settings
run: |
@@ -133,7 +136,7 @@ jobs:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: ${{ matrix.python-version }}
@@ -186,7 +189,7 @@ jobs:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
+60
View File
@@ -31,6 +31,66 @@ All notable changes to this project are documented in this file.
### Fixed
- **`mjr_am_backend.adapters.tools` missing from Registry installs**: `.comfyignore`'s unanchored `tools/` pattern also matched `mjr_am_backend/adapters/tools/`, stripping the ExifTool/FFProbe adapters out of every Registry-published package and causing `ModuleNotFoundError: No module named 'mjr_am_backend.adapters.tools'` at startup. The pattern is now anchored to the repo-root `/tools/` dev-scripts folder only.
### New Features (viewer)
- **Studio viewer (new default main viewer)**: a simpler, modern viewer for reviewing generations — header with navigation and file summary, zoom/pan image stage with transparency checkerboard, a new video/audio player (frame stepping, frame counter, A-B loop, speed, volume, fullscreen, save frame as PNG, auto-hiding controls), an inspector with rating, tags and the same Generation / Workflow / File data as the asset sidebar, and a filmstrip. Keyboard-first (see the viewer tutorial). *Viewer → Use the classic viewer* switches back entirely.
- **Floating viewer: Studio player and refreshed look**: in Simple mode, video and audio now play in the Studio player (compact, width-aware controls; same frame stepping, A-B loop, speed, volume, fullscreen and frame export as the main viewer), keeping the floating viewer's keyboard forwarding, mute/speed persistence and media release. The floating window gets the Studio styling (flat buttons that light up on hover or when active, softer window chrome, neutral stage). Compare modes keep their synchronised players; *Use the classic viewer* also restores the legacy floating player.
- **Studio viewer: every tool of the classic viewer, as a HUD**: a tool rail on the left of the stage brings channels (R/G/B/alpha/luma), exposure and gamma, zebra, histogram / waveform scopes, composition guides (thirds, golden, centre, safe areas), aspect-ratio masks, pixel probe, loupe, copy to clipboard and A/B compare (wipe ↔/↕, side by side, difference, add, subtract, multiply, screen) with B selection, pin and swap. Colour tools run on the GPU and work on playing video; video compare keeps both players in sync. 3D models and the grid's A/B / side-by-side actions now open in the Studio viewer too, and audio files show a live spectrum. New shortcuts: `C`, `B`, `X`, `Z`, `S`, `G`, `P`, `L`, `R`, `Ctrl+C`.
### Removed
- **Classic viewer**: the Studio viewer is now the only main viewer. The classic overlay (toolbar, panzoom, A/B and side-by-side engine, scopes, loupe, probe, grading pipeline, WebGL video processor, filmstrip and its context menu) and about 20 000 lines of code, styles and tests are gone. Everything it did is in the Studio viewer's tool rail; the grid's A/B / side-by-side actions open the Studio viewer with that compare mode on.
- **Viewer settings**: *Use the classic viewer*, *Pan without zoom* and *Disable WebGL video* (classic-only) are removed, with the internal `videoGradeThrottleFps`, `scopesFps`, `metaTtlMs` and `metaMaxEntries` options. Existing values in saved settings are simply ignored.
- The unused *viewerbar* variant of the legacy video controls (the floating viewer's compare modes keep the regular variant).
### Changed
- **Floating viewer: one player everywhere**: A/B, side-by-side and grid compare now also use the Studio player for the primary video / audio (the other slots stay bare, synced elements). The legacy video controls (in/out, Nuke-style bar, ~2 000 lines, their styles and tests) are gone; the asset sidebar keeps its small preview controls. GIF / animated WebP keep their own simple player.
### Fixed (follow-ups)
- **Floating viewer compare modes ignored the Studio player's video**: zoom, pan, exposure / channel tone and the overlay guides only addressed bare media elements, so in A/B, side-by-side and grid the primary video (the Studio player) stayed untouched while the other slots changed. The player's video now takes part in all of them.
- **Floating viewer A/B divider drew over the player controls**: it now stops above the control bar (and extends fully again when the controls auto-hide).
- **Studio compare of different sizes**: B keeps its own aspect ratio instead of being stretched to A's frame (wipe, blend and side by side).
- **Floating viewer topbar button position**: the *Viewer* button sometimes landed at the far end of the actionbar after reloading the interface. The Run group is rendered asynchronously inside the actionbar and its late arrival was never observed; the button now re-anchors next to Run as soon as it appears.
- **Floating viewer A/B hid the player**: with the Studio player on A, the B layer covered the right half of its controls. A now sits on top and only its picture is clipped at the divider, so the controls stay visible and usable everywhere.
- **Light dismiss in the Studio viewer**: clicking the empty area around the image or video (not the picture, a tool, the inspector or the player bar) closes the viewer again, as the classic viewer did. Dragging to pan never closes it.
- **Grid selection after closing the viewer**: the Studio viewer now tells the grid which asset you ended on (selection, scroll and focus), as the classic viewer did.
- **Studio viewer shortcuts after navigating**: moving to another asset focused the tag field, so the next shortcuts were typed into it. The inspector's tag editor no longer grabs focus.
- **Search by part of a file name**: typing `F` (or `flow`) only found names with a *word starting* with it (`Flux…`, `field…`), never `sunflower.png` or `ComfyUI_…`. Single-word searches now also list file names that contain the text (ranked after word matches); `_` and `%` are matched literally. Multi-word and quoted searches are unchanged.
- **Sidecar JSON added after a file was indexed** is now picked up: the watcher maps a new/changed `<file>.json` / `<stem>.json` to its media, invalidates that file's metadata cache and scan journal, and forces a re-extraction (a non-incremental re-index no longer trusts an existing metadata row when the cache was invalidated).
- **Duplicate index rows for one file (Windows)**: rows whose paths differ only by case or separator (left by older versions) are merged at startup, keeping the highest rating, all tags, the stack cover, execution ids and the embedding.
- **Generated feed** drops items deleted from the UI or removed on disk.
- **Floating viewer shortcuts** (`v`, `k`, `l`, `c`) only act when the pointer or focus is in the viewer (or from its pop-out), instead of swallowing those keys page-wide while it is open.
- Types now come from `@comfyorg/comfyui-frontend-types` (dropped the separate `@comfyorg/litegraph` package); removed 5 dead frontend files; fixed an order-dependent backend test that leaked an input-directory override.
### Fixed (search, JSON, viewers)
- **Search errors while typing**: queries such as `at-7`, `(2)`, `AND`, `NOT x`, a single letter or a lone `"` returned "Invalid search query syntax" because every token was filtered out (stopword, 1-char, operator word) and an empty FTS `MATCH` was sent. Search now falls back to the raw words, treats punctuation-only input as browse-all, and keeps non-Latin scripts (Cyrillic, CJK...) that were stripped before; only Latin accents are folded, matching the FTS5 tokenizer.
- **Sidecar JSON for images, videos and audio**: `<file>.<ext>.json` or `<stem>.json` next to a media file now provides its workflow / prompt / generation data when nothing is embedded (previously 3D models only). Embedded metadata always wins; UTF-8 BOM is accepted.
- **Workflow tab listed non-workflows and version snapshots**: every `*.json` under a workflow root became a card, including `.history/<name>/<stamp>.json` version snapshots and arbitrary JSON (settings, node configs). Hidden folders are skipped and only UI graph exports, API prompt graphs and `{workflow}`/`{prompt}` wrappers are listed (`workflow_format`: `ui`/`api`). BOM-prefixed workflows are no longer silently dropped; stale rows are purged (workflow index version 5).
- **API-format workflows opened as an empty canvas**: they are now loaded with ComfyUI's `app.loadApiJson`; `{workflow: ...}` exports are unwrapped.
- **Floating viewer leaked media players**: re-rendering (every generation in Live mode) and disposing only paused videos before detaching them; Chromium caps live media players, so videos could stop playing. Media is now released (src, `<source>`, `load()`), as the main viewer already did.
### Fixed (duplicate stacks)
- **Clicking a duplicate-stack badge froze ComfyUI**: three passes (`displayAssets`, the per-card ref callback `syncRenderedDuplicateCards`, the live-upsert dedupe) each rewrote `_mjrDupStack` / `_mjrDupMembers` on reactive assets read by the grid template, with a fresh array on every pass and conflicting representatives. The ref callback ran on every render (a new closure each time), so every write scheduled another render: an endless update loop (production Vue has no recursion limit). Duplicate stacks are now computed in one place with change-guarded writes, card refs are stable, and the other passes no longer touch these fields.
- **"Corrupted" duplicate stacks**: stacks were also built from the file name alone across folders, so unrelated files sharing a name (e.g. frame grabs `at-7.958s.png` from different videos) were stacked, hidden from the grid and given another file's generation time. A duplicate is now the same name in the same source/root/subfolder only.
- **Duplicates view collapsed back to one card**: the "Duplicates - N copies" view re-grouped its own members; it now lists every copy, using cycle-free copies of the members.
### Fixed (asset sync)
- **Renamed / moved files lost their ratings, tags and collections**: the `assets_fts` triggers ran a plain `UPDATE`/`DELETE` on an external-content FTS5 table, so every filename/subfolder change failed with "database disk image is malformed" (and could start a needless DB recovery). The watcher then fell back to delete + re-index, creating a new asset id. Triggers now use FTS5's `'delete'` command (update trigger limited to `filename, subfolder`); existing databases are repaired and the FTS index rebuilt automatically at startup.
- **Outputs missed after a generation**: ComfyUI calls `on_prompt_end` before it stores the prompt history, so post-execution ingestion could read an empty history and index nothing (prompts queued from the API, another tab or another device). Ingestion now waits briefly for the history entry.
- **Scan prune could wipe user data**: a stat error (offline share, permission hiccup) counted as "file deleted". Like ComfyUI core's reconciler, only a confirmed absence removes an asset, and an unreadable scan root is never pruned. The `pruned` count no longer includes cascade/trigger rows.
- **Stale cards after external deletes/moves**: files removed or moved outside Majoor (watcher, scan prune) now emit `mjr-assets-removed` / `mjr-asset-updated`, so open grids update in place.
- **Background scans never refreshed the grid**: `mjr-scan-complete` now carries `origin` and `needs_refresh`; the grid reloads once when live events could not cover the change, and a finished directory scan shows one ComfyUI core toast ("N new, M removed"). Per-generation index runs stay silent (live cards + sidebar badge).
### Changed (ComfyUI integration)
- **Compat assets API moved to `/mjr/am/compat/assets/*`**: the read-only OpenAPI-aligned surface no longer squats ComfyUI core's `/api` namespace. The old `/api/v2/assets/*` paths are only registered when `MJR_AM_LEGACY_API_V2_ASSETS=1` (deprecated).
- **Python 3.14**: `requires-python` is now `>=3.10, <3.15`; CI runs the full suite on 3.14.
- **Execution tracking without wrapping `api.queuePrompt`**: the floating viewer progress reads the running prompt from ComfyUI's Jobs API (`GET /api/jobs/{id}`), then `/queue` and `/history/{id}` on older cores, driven by websocket events. No more conflicts with other extensions that wrap `queuePrompt`.
- **Optional: hide ComfyUI's native Assets tab** (`Majoor.Integration.HideNativeAssetsTab`, off by default). Uses the official `getSidebarTabs`/`unregisterSidebarTab` API and restores the tab when turned off.
- **Workflow serialization** uses the root graph instead of the async `graphToPrompt()`; dirty state reads the frontend workflow store.
- **Typed aiohttp request keys** (`web.RequestKey`) — no more `NotAppKeyWarning` on aiohttp ≥ 3.13.
- **Notifications on ComfyUI core toasts only**: Majoor now uses the typed ToastManager API (`success/error/info/warning/loading`) instead of the deprecated `add()` (kept as fallback for older frontends). Long operations (DB restore/reset, vector backfill) show one `loading` toast updated in place instead of stacking a toast per step; links become native toast action buttons; in-progress toasts are dismissed on teardown. Toasts no longer disappear if ComfyUI removes `add()`. The Majoor message center stays as a read-only history (release notes, toast log, shortcuts) and no longer pops open by itself.
- **Frontend test typing**: `tsconfig.test.json` now type-checks with 0 errors (was 913) and runs in CI.
## [2.5.2] - 2026-09-27
### Changed
+1 -1
View File
@@ -716,7 +716,7 @@ See [`docs/TESTING.md`](docs/TESTING.md), [`tests/README.md`](tests/README.md),
- Linux (Ubuntu 22.04+, Fedora, Debian)
- **Browsers**: Modern browsers with ES2020+ support
The automated CI matrix currently validates Python 3.10-3.13 and the frontend on Ubuntu. Windows and macOS support is maintained through portable code paths and contributor/user testing rather than dedicated hosted CI jobs.
The automated CI matrix currently validates Python 3.10-3.14 and the frontend on Ubuntu. Windows and macOS support is maintained through portable code paths and contributor/user testing rather than dedicated hosted CI jobs.
---
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
import { J as e, o as t, r as n } from "./events-C65wdOHm.js";
import { t as r } from "./floatingViewerManager-BzaQii21.js";
import { Vn as e, t } from "./config-C9a-wT5M.js";
import { t as n } from "./floatingViewerManager-B_I-SRbd.js";
import { r } from "./events-1iwo5blr.js";
// ui/features/viewer/LiveStreamTracker.ts
var i = !1, a = null, o = null, s = null, c = null, l = null, u = null, d = null, f = null, p = 0, m = 0, h = !1, g = 400, _ = "kj_preview_override", v = /* @__PURE__ */ new Set([
"image/jpeg",
@@ -74,12 +75,12 @@ function O(e) {
let t = String(e?.node_id ?? "").trim(), n = Number(e?.step), r = Number(e?.total), i = Number.isFinite(n) && Number.isFinite(r) && r > 0 ? ` · ${n}/${r}` : "";
return `KJ Preview Override${t ? ` · Node ${t}` : ""}${i}`;
}
async function k(n) {
async function k(r) {
let i = ++p;
try {
A();
let a = await e({
app: n,
app: r,
timeoutMs: 8e3
});
if (i !== p) return;
@@ -97,40 +98,40 @@ async function k(n) {
h = !1;
return;
}
if (!r.canAcceptPreviewBlob()) return;
let n = e?.detail || null, i = D(n);
if (!n.canAcceptPreviewBlob()) return;
let r = e?.detail || null, i = D(r);
if (!i) return;
h = !0;
let a = String(n?.node_id ?? "").trim();
r.feedPreviewBlob(i, {
let a = String(r?.node_id ?? "").trim();
n.feedPreviewBlob(i, {
source: "kj-preview-override",
sourceLabel: O(n),
sourceLabel: O(r),
nodeId: a || null,
mime: i.type,
width: Number(n?.w) || void 0,
height: Number(n?.h) || void 0,
fps: Number(n?.fps) || void 0,
step: Number.isFinite(Number(n?.step)) ? Number(n.step) : null,
total: Number.isFinite(Number(n?.total)) ? Number(n.total) : null
width: Number(r?.w) || void 0,
height: Number(r?.h) || void 0,
fps: Number(r?.fps) || void 0,
step: Number.isFinite(Number(r?.step)) ? Number(r.step) : null,
total: Number.isFinite(Number(r?.total)) ? Number(r.total) : null
});
} catch (e) {
console.debug?.("[MFV] KJNodes preview override error", e);
}
}, a.addEventListener(_, c), s = (e) => {
try {
if (h && t.MFV_KJ_PREVIEW_OVERRIDE_ENABLED !== !1 || !r.canAcceptPreviewBlob()) return;
let { blob: n, nodeId: i, jobId: a } = e.detail || {};
if (!n || !(n instanceof Blob) || (m = Date.now(), f && a && a !== f)) return;
r.feedPreviewBlob(n, { sourceLabel: i ? `Node ${i}` : null });
if (h && t.MFV_KJ_PREVIEW_OVERRIDE_ENABLED !== !1 || !n.canAcceptPreviewBlob()) return;
let { blob: r, nodeId: i, jobId: a } = e.detail || {};
if (!r || !(r instanceof Blob) || (m = Date.now(), f && a && a !== f)) return;
n.feedPreviewBlob(r, { sourceLabel: i ? `Node ${i}` : null });
} catch (e) {
console.debug?.("[MFV] b_preview_with_metadata error", e);
}
}, a.addEventListener("b_preview_with_metadata", s), o = (e) => {
try {
if (h && t.MFV_KJ_PREVIEW_OVERRIDE_ENABLED !== !1 || T() || !r.canAcceptPreviewBlob()) return;
let n = e.detail;
if (!n || !(n instanceof Blob)) return;
r.feedPreviewBlob(n);
if (h && t.MFV_KJ_PREVIEW_OVERRIDE_ENABLED !== !1 || T() || !n.canAcceptPreviewBlob()) return;
let r = e.detail;
if (!r || !(r instanceof Blob)) return;
n.feedPreviewBlob(r);
} catch (e) {
console.debug?.("[MFV] preview blob error", e);
}
@@ -187,17 +188,17 @@ function M(e) {
function N(e) {
a || (i = !0, a = (e) => {
try {
if (!r.getLiveActive()) return;
if (!n.getLiveActive()) return;
let t = M(e.detail?.files);
if (!t) return;
r.upsertWithContent(t);
n.upsertWithContent(t);
} catch (e) {
console.debug?.("[MFV] generation output error", e);
}
}, typeof window < "u" && window.addEventListener(n.NEW_GENERATION_OUTPUT, a), k(e), console.debug("[Majoor] LiveStreamTracker initialized"));
}, typeof window < "u" && window.addEventListener(r.NEW_GENERATION_OUTPUT, a), k(e), console.debug("[Majoor] LiveStreamTracker initialized"));
}
function P(e) {
a &&= (typeof window < "u" && window.removeEventListener(n.NEW_GENERATION_OUTPUT, a), null), p += 1, A(), f = null, i = !1, console.debug("[Majoor] LiveStreamTracker torn down");
a &&= (typeof window < "u" && window.removeEventListener(r.NEW_GENERATION_OUTPUT, a), null), p += 1, A(), f = null, i = !1, console.debug("[Majoor] LiveStreamTracker torn down");
}
function F() {
return i;
File diff suppressed because it is too large Load Diff
-291
View File
@@ -1,291 +0,0 @@
import { F as e, en as t, v as n } from "./viewerRuntimeHosts-n0pKLECw.js";
import { St as r, m as i, n as a } from "./events-C65wdOHm.js";
import { Y as o } from "./Viewer-W-G40j3K.js";
import { A as s, C as c, E as l, G as u, J as d, K as f, L as ee, N as p, R as te, T as m, U as ne, V as h, Y as g, b as _, ft as v, j as y, k as b, lt as x, rt as S, ut as C, z as w } from "./mjr-primevue-BRqtdXzb.js";
// ui/vue/components/common/TagsEditor.vue?vue&type=script&setup=true&lang.ts
var T = ["aria-busy"], E = ["aria-label"], re = {
key: 0,
class: "mjr-tags-empty"
}, ie = { class: "mjr-tags-input-wrap" }, ae = ["aria-selected", "onMouseenter"], oe = 100, D = 200, O = /* @__PURE__ */ p({
__name: "TagsEditor",
props: {
asset: {},
modelValue: {},
disabled: { type: Boolean }
},
emits: ["update:modelValue", "tags-change"],
setup(p, { emit: O }) {
function se(e) {
try {
let t = String(e ?? "").trim();
if (!t || t.length > oe) return null;
for (let e = 0; e < t.length; e += 1) {
let n = t.charCodeAt(e);
if (n <= 31 || n === 127) return null;
}
return /[;,]/.test(t) ? null : t;
} catch {
return null;
}
}
function k(e) {
try {
return String(e ?? "").replace(/[\x00-\x1f\x7f]/g, "").trim() || null;
} catch {
return null;
}
}
function A(e) {
let t = k(e);
return t ? t.toLowerCase() : "";
}
function j(e) {
let t = [], n = /* @__PURE__ */ new Set();
for (let r of Array.isArray(e) ? e : []) {
let e = k(r);
if (!e) continue;
let i = e.toLowerCase();
if (!n.has(i) && (n.add(i), t.push(e), t.length >= D)) break;
}
return t;
}
function M(e) {
if (Array.isArray(e)) return j(e);
if (typeof e == "string") {
let t = e.trim();
if (!t) return [];
try {
let e = JSON.parse(t);
if (Array.isArray(e)) return j(e);
} catch {
return j(t.split(","));
}
}
return [];
}
function ce(e, t) {
if (!Array.isArray(e) || !Array.isArray(t) || e.length !== t.length) return !1;
for (let n = 0; n < e.length; n += 1) if (e[n] !== t[n]) return !1;
return !0;
}
let N = p, P = O, F = M(N.asset?.tags ?? N.modelValue ?? []), I = S([...F]), L = S(null), R = S(""), z = S(!1), B = S(-1), V = S(null), H = S([]), U = S(!1), W = (e) => e?.$el || e || null, G = !1, K = !1, q = null, J = [...F], le = (e) => Math.min(100 * 2 ** Math.max(0, e - 1), 2e3), ue = (e) => new Promise((t) => setTimeout(t, e)), de = () => setTimeout(() => z.value = !1, 200), Y = m(() => {
let e = R.value.toLowerCase().trim();
if (!e) return [];
let t = new Set(I.value.map((e) => A(e)).filter(Boolean));
return H.value.filter((n) => {
let r = A(n);
return n.toLowerCase().includes(e) && (!r || !t.has(r));
}).slice(0, 10);
}), X = m(() => Y.value.length > 0);
function Z(e) {
if (G) return;
let t = M(e);
J = [...t], ce(t, I.value) || (I.value = [...t]);
}
function fe(e) {
let t = [...e], n = N.asset?.id == null ? "" : String(N.asset.id);
P("update:modelValue", t), P("tags-change", {
assetId: N.asset?.id,
tags: t
}), o(a, {
assetId: n,
tags: t
});
}
async function Q() {
if (N.disabled) return;
if (G) {
K = !0;
try {
q?.abort?.();
} catch (e) {
console.debug?.(e);
}
return;
}
G = !0, U.value = !0;
let n = 0;
for (; n < 10;) {
n > 0 && await ue(le(n)), n += 1;
let a = M(I.value), o = typeof AbortController < "u" ? new AbortController() : null;
q = o;
let s = null;
try {
s = await e(N.asset, a, o ? { signal: o.signal } : {});
} catch (e) {
console.error("Failed to update tags:", e);
}
if (!s?.ok) {
if (s?.code === "ABORTED") {
if (K) {
K = !1;
continue;
}
break;
}
let e = [...J];
I.value = e, P("update:modelValue", e), t(s?.error || i("toast.tagsUpdateFailed", "Failed to update tags"), "error"), G = !1, q = null, U.value = !1;
return;
}
try {
let e = s?.data?.asset_id ?? null;
e != null && !r(N.asset.id) && (N.asset.id = e);
} catch (e) {
console.debug?.(e);
}
let c = M(Array.isArray(s?.data?.tags) ? s.data.tags : a);
J = [...c];
try {
N.asset.tags = [...c];
} catch (e) {
console.debug?.(e);
}
if (K || (I.value = [...c]), fe(c), t(i("toast.tagsUpdated", "Tags updated"), "success", 1e3), !K) break;
K = !1;
}
if (n >= 10) {
let e = [...J];
I.value = e;
try {
N.asset.tags = [...e];
} catch (e) {
console.debug?.(e);
}
P("update:modelValue", e), t(i("toast.tagsUpdateFailed", "Failed to update tags"), "error");
}
G = !1, q = null, U.value = !1;
}
w(async () => {
try {
await ee(), W(L.value)?.focus();
} catch (e) {
console.debug?.(e);
}
try {
let e = await n();
e?.ok && Array.isArray(e?.data) && (H.value = j(e.data));
} catch (e) {
console.warn("Failed to load available tags:", e);
}
}), te(() => {
try {
q?.abort?.();
} catch (e) {
console.debug?.(e);
}
});
function $(e) {
if (N.disabled) return;
let n = se(e);
if (!n) return;
let r = A(n);
r && (I.value.some((e) => A(e) === r) || (I.value.length >= D ? t(i("toast.maxTagsReached", "Maximum number of tags reached"), "warning") : (I.value = [...I.value, n], Q())));
}
function pe(e) {
if (N.disabled || e < 0 || e >= I.value.length) return;
let t = [...I.value];
t.splice(e, 1), I.value = t, Q();
}
function me(e) {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
let t = (z.value && X.value && B.value >= 0 ? Y.value[B.value] : null) || R.value.trim();
t && ($(t), R.value = "", z.value = !1, B.value = -1, V.value = null);
} else if (e.key === "Escape") z.value = !1, B.value = -1, V.value = null;
else if (e.key === "ArrowDown") e.preventDefault(), X.value && (B.value = Math.min(B.value + 1, Y.value.length - 1));
else if (e.key === "ArrowUp") e.preventDefault(), B.value = Math.max(B.value - 1, 0);
else if (e.key === "Tab" && X.value) {
e.preventDefault();
let t = Y.value[B.value] || Y.value[0];
t && (R.value = t);
}
}
function he(e) {
$(e), R.value = "", z.value = !1, B.value = -1, V.value = null, W(L.value)?.focus();
}
function ge(e) {
let t = e?.value ?? V.value;
t && he(t);
}
return f(() => N.modelValue, (e) => {
Z(e);
}), f(() => N.asset?.tags, (e) => {
Z(e);
}), (e, t) => {
let n = u("MButton"), r = u("MInputText"), a = u("MListbox");
return h(), b("div", {
class: C(["mjr-tags-editor", { "is-disabled": p.disabled }]),
"aria-busy": U.value
}, [l("div", {
class: "mjr-tags-display",
role: "list",
"aria-label": x(i)("tags.label", "Tags")
}, [I.value.length === 0 ? (h(), b("span", re, v(x(i)("msg.noTagsYet", "No tags yet...")), 1)) : (h(!0), b(c, { key: 1 }, ne(I.value, (e, r) => (h(), b("div", {
key: e,
class: "mjr-tag-chip",
role: "listitem"
}, [s(v(e) + " ", 1), y(n, {
type: "button",
class: "mjr-tag-chip-remove",
severity: "secondary",
text: "",
rounded: "",
"aria-label": x(i)("tags.remove", "Remove tag"),
disabled: p.disabled,
onClick: (e) => pe(r)
}, {
default: d(() => [...t[4] ||= [s(" x ", -1)]]),
_: 1
}, 8, [
"aria-label",
"disabled",
"onClick"
])]))), 128))], 8, E), l("div", ie, [y(r, {
ref_key: "inputRef",
ref: L,
modelValue: R.value,
"onUpdate:modelValue": t[0] ||= (e) => R.value = e,
type: "text",
class: "mjr-tag-input",
placeholder: x(i)("sidebar.addTag", "Add tag..."),
disabled: p.disabled,
"aria-label": x(i)("tags.addLabel", "Add tag"),
"aria-autocomplete": X.value ? "list" : "none",
"aria-expanded": z.value,
"aria-haspopup": X.value ? "listbox" : void 0,
onFocus: t[1] ||= (e) => z.value = R.value.length > 0,
onInput: t[2] ||= (e) => z.value = R.value.length > 0,
onKeydown: me,
onBlur: de
}, null, 8, [
"modelValue",
"placeholder",
"disabled",
"aria-label",
"aria-autocomplete",
"aria-expanded",
"aria-haspopup"
]), g(y(a, {
modelValue: V.value,
"onUpdate:modelValue": t[3] ||= (e) => V.value = e,
options: Y.value,
class: "mjr-tags-dropdown",
"scroll-height": "150px",
"aria-label": x(i)("tags.suggestions", "Tag suggestions"),
onChange: ge
}, {
option: d(({ option: e, index: t }) => [l("div", {
class: C(["mjr-tag-suggestion", { "is-active": t === B.value }]),
"aria-selected": t === B.value,
onMouseenter: (e) => B.value = t
}, v(e), 43, ae)]),
_: 1
}, 8, [
"modelValue",
"options",
"aria-label"
]), [[_, z.value && X.value]])])], 10, T);
};
}
});
export { O as t };
+4970
View File
File diff suppressed because it is too large Load Diff
-11411
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-342
View File
@@ -1,342 +0,0 @@
import { a as e, i as t } from "./viewerRuntimeHosts-n0pKLECw.js";
import { r as n } from "./events-C65wdOHm.js";
import { a as r, c as i, i as a, l as o, o as s, s as c, t as l, u } from "./Viewer-W-G40j3K.js";
import { i as d, r as f } from "./floatingViewerManager-BzaQii21.js";
import { B as p, C as m, D as h, E as g, G as _, J as v, K as y, L as b, N as x, O as S, T as C, U as w, V as T, dt as E, ft as D, j as O, k, lt as A, rt as j, ut as M, w as N, z as P } from "./mjr-primevue-BRqtdXzb.js";
import { t as F } from "./TagsEditor-2AmCKykg.js";
// ui/vue/components/viewer/FloatingViewerHost.vue
var I = /* @__PURE__ */ x({
__name: "FloatingViewerHost",
setup(e) {
let n = j(null), r = null;
return P(() => {
r = t(n.value);
}), p(() => {
r?.(), r = null;
}), (e, t) => (T(), k("div", {
ref_key: "hostRef",
ref: n,
class: "mjr-viewer-runtime-host mjr-viewer-runtime-host--floating",
style: {
position: "fixed",
inset: "0",
"pointer-events": "none",
overflow: "visible"
}
}, null, 512));
}
}), L = /* @__PURE__ */ x({
__name: "ViewerOverlayHost",
setup(t) {
let n = j(null), r = null;
return P(() => {
r = e(n.value);
}), p(() => {
r?.(), r = null;
}), (e, t) => (T(), k("div", {
ref_key: "hostRef",
ref: n,
class: "mjr-viewer-runtime-host mjr-viewer-runtime-host--main",
style: {
position: "fixed",
inset: "0",
"pointer-events": "none",
overflow: "visible"
}
}, null, 512));
}
}), R = {
key: 0,
class: "mjr-context-menu-separator"
}, ee = { class: "mjr-context-menu-item-left" }, te = { class: "mjr-context-menu-item-right" }, z = {
key: 0,
class: "mjr-context-menu-hint"
}, B = {
key: 1,
class: "mjr-context-menu-submenu-arrow"
}, V = {
key: 0,
class: "mjr-context-menu-separator"
}, H = { class: "mjr-context-menu-item-left" }, U = {
key: 0,
class: "mjr-context-menu-hint"
}, W = /* @__PURE__ */ x({
__name: "ViewerContextMenu",
setup(e) {
let t = j(null), n = j(null), a = j(null), o = null, c = null, l = C(() => x(u.main, 10041)), d = C(() => x(u.submenu, 10042)), f = C(() => x(u.tags, 10043));
function x(e, t) {
return {
position: "fixed",
left: `${Math.round(Number(e?.x) || 0)}px`,
top: `${Math.round(Number(e?.y) || 0)}px`,
display: "block",
zIndex: String(t)
};
}
function I() {
o &&= (clearTimeout(o), null);
}
function L() {
I(), o = setTimeout(() => {
s();
}, 180);
}
function W(e, t) {
if (!e?.open || !t) return;
let n = t.getBoundingClientRect(), r = Number(window.innerWidth || 0), i = Number(window.innerHeight || 0), a = Number(e.x) || 0, o = Number(e.y) || 0;
a + n.width > r && (a = Math.max(8, r - n.width - 10)), o + n.height > i && (o = Math.max(8, i - n.height - 10)), a < 8 && (a = 8), o < 8 && (o = 8), e.x = a, e.y = o;
}
async function G(e, t) {
await b(), W(e, t?.value || null);
}
function K(e) {
try {
e?.value?.querySelector?.(".mjr-context-menu-item:not([aria-disabled=\"true\"])")?.focus?.();
} catch (e) {
console.debug?.(e);
}
}
function q(e, t) {
if (!Array.isArray(e?.submenu) || !e.submenu.length) {
s();
return;
}
I();
let n = (t?.currentTarget)?.getBoundingClientRect?.();
i({
x: Math.round((n?.right || u.main.x || 0) + 6),
y: Math.round((n?.top || u.main.y || 0) - 4),
items: e.submenu,
title: e.label || ""
});
}
async function J(e, t, n = "main") {
if (e && e.type === "item" && !e.disabled) {
if (Array.isArray(e.submenu) && e.submenu.length) q(e, t);
else try {
await e.action?.();
} catch (e) {
console.error("[ViewerContextMenu.vue] Action failed:", e);
} finally {
e.closeOnSelect === !1 ? n === "submenu" && s() : r();
}
}
}
function Y(e, t) {
Array.isArray(e?.submenu) && e.submenu.length ? q(e, t) : s();
}
function X(e) {
Array.isArray(e?.submenu) && e.submenu.length && L();
}
function Z() {
I();
}
function ne() {
L();
}
function re(e) {
let i = e?.target;
t.value?.contains?.(i) || n.value?.contains?.(i) || a.value?.contains?.(i) || r();
}
function Q(e) {
e?.key === "Escape" && r();
}
function $() {
r();
}
function ie(e) {
let t = e?.detail;
String(t?.source || "") !== "viewer" && r();
}
function ae(e) {
let t = u.tags.asset;
t && (t.tags = Array.isArray(e) ? [...e] : []);
}
function oe(e) {
let t = Array.isArray(e?.tags) ? e.tags : [];
try {
u.tags.onChanged?.(t);
} catch (e) {
console.debug?.(e);
}
}
return y(() => u.main.open, async (e) => {
e && (await G(u.main, t), K(t));
}), y(() => u.submenu.open, async (e) => {
e && (await G(u.submenu, n), K(n));
}), y(() => u.tags.open, async (e) => {
e && await G(u.tags, a);
}), P(() => {
c = new AbortController();
let e = {
capture: !0,
passive: !0,
signal: c.signal
};
window.addEventListener("pointerdown", re, e), window.addEventListener("keydown", Q, {
capture: !0,
signal: c.signal
}), window.addEventListener("scroll", $, e), window.addEventListener("wheel", $, e), window.addEventListener("resize", $, {
passive: !0,
signal: c.signal
}), window.addEventListener("mjr-close-all-menus", ie, { signal: c.signal });
}), p(() => {
I();
try {
c?.abort();
} catch (e) {
console.debug?.(e);
}
c = null, r();
}), (e, r) => {
let i = _("MButton");
return T(), h(N, { to: "body" }, [
A(u).main.open ? (T(), k("div", {
key: 0,
ref_key: "mainMenuRef",
ref: t,
class: "mjr-viewer-context-menu mjr-context-menu",
style: E(l.value),
role: "menu",
"aria-label": "Viewer context menu"
}, [(T(!0), k(m, null, w(A(u).main.items, (e) => (T(), k(m, { key: e.id }, [e.type === "separator" ? (T(), k("div", R)) : (T(), h(i, {
key: 1,
type: "button",
class: M(["mjr-context-menu-item", {
"is-disabled": e.disabled,
"has-submenu": Array.isArray(e.submenu) && e.submenu.length
}]),
severity: "secondary",
text: "",
role: "menuitem",
"aria-disabled": e.disabled ? "true" : "false",
tabindex: e.disabled ? -1 : 0,
onClick: (t) => J(e, t),
onMouseenter: (t) => Y(e, t),
onMouseleave: (t) => X(e)
}, {
default: v(() => [g("span", ee, [e.iconClass ? (T(), k("i", {
key: 0,
class: M(e.iconClass)
}, null, 2)) : S("", !0), g("span", null, D(e.label), 1)]), g("span", te, [e.rightHint ? (T(), k("span", z, D(e.rightHint), 1)) : S("", !0), Array.isArray(e.submenu) && e.submenu.length ? (T(), k("span", B, " > ")) : S("", !0)])]),
_: 2
}, 1032, [
"class",
"aria-disabled",
"tabindex",
"onClick",
"onMouseenter",
"onMouseleave"
]))], 64))), 128))], 4)) : S("", !0),
A(u).submenu.open ? (T(), k("div", {
key: 1,
ref_key: "submenuRef",
ref: n,
class: "mjr-viewer-rating-submenu mjr-context-menu",
style: E(d.value),
role: "menu",
"aria-label": "Viewer context submenu",
onMouseenter: Z,
onMouseleave: ne
}, [(T(!0), k(m, null, w(A(u).submenu.items, (e) => (T(), k(m, { key: e.id }, [e.type === "separator" ? (T(), k("div", V)) : (T(), h(i, {
key: 1,
type: "button",
class: M(["mjr-context-menu-item", { "is-disabled": e.disabled }]),
severity: "secondary",
text: "",
role: "menuitem",
"aria-disabled": e.disabled ? "true" : "false",
tabindex: e.disabled ? -1 : 0,
onClick: (t) => J(e, t, "submenu")
}, {
default: v(() => [g("span", H, [e.iconClass ? (T(), k("i", {
key: 0,
class: M(e.iconClass)
}, null, 2)) : S("", !0), g("span", null, D(e.label), 1)]), e.rightHint ? (T(), k("span", U, D(e.rightHint), 1)) : S("", !0)]),
_: 2
}, 1032, [
"class",
"aria-disabled",
"tabindex",
"onClick"
]))], 64))), 128))], 36)) : S("", !0),
A(u).tags.open && A(u).tags.asset ? (T(), k("div", {
key: 2,
ref_key: "tagsPopoverRef",
ref: a,
class: "mjr-viewer-popover",
style: E(f.value)
}, [O(F, {
asset: A(u).tags.asset,
"model-value": A(u).tags.asset?.tags || [],
"onUpdate:modelValue": ae,
onTagsChange: oe
}, null, 8, ["asset", "model-value"])], 4)) : S("", !0)
]);
};
}
}), G = /* @__PURE__ */ x({
__name: "ViewerContextMenuPortal",
setup(e) {
let t = j(""), n = C(() => c(t.value));
return P(() => {
t.value = a();
}), p(() => {
o(t.value), t.value = "";
}), (e, t) => n.value ? (T(), h(W, { key: 0 })) : S("", !0);
}
}), K = /* @__PURE__ */ x({
__name: "ViewerPortal",
setup(e) {
let t = null;
function r(e) {
let n = e?.detail || {}, r = Array.isArray(n?.assets) ? n.assets.filter(Boolean) : n?.asset ? [n.asset] : [];
if (!r.length) return;
let i = Math.max(0, Math.min(Number(n?.index) || 0, r.length - 1)), a = String(n?.mode || "").trim().toLowerCase();
try {
t ||= l(), t.open?.(r, i), (a === "ab" || a === "sidebyside") && t.setMode?.(a), n.handled = !0;
} catch (e) {
console.debug?.(e);
}
}
return P(() => {
try {
f();
} catch (e) {
console.debug?.(e);
}
try {
t = l();
} catch (e) {
console.debug?.(e);
}
try {
window.addEventListener(n.OPEN_VIEWER, r);
} catch (e) {
console.debug?.(e);
}
}), p(() => {
try {
window.removeEventListener(n.OPEN_VIEWER, r);
} catch (e) {
console.debug?.(e);
}
try {
d();
} catch (e) {
console.debug?.(e);
}
try {
t?.dispose?.();
} catch (e) {
console.debug?.(e);
}
t = null;
}), (e, t) => (T(), k(m, null, [
O(L),
O(I),
O(G)
], 64));
}
});
export { K as default };
-451
View File
@@ -1,451 +0,0 @@
// ui/features/viewer/abCompare.ts
function e({ abView: e, state: t, currentAsset: n, viewUrl: r, buildAssetViewURL: i, createCompareMediaElement: a, destroyMediaProcessorsIn: o } = {}) {
try {
o?.(e);
} catch (e) {
console.debug?.(e);
}
try {
e && (e.innerHTML = "");
} catch (e) {
console.debug?.(e);
}
try {
e?._mjrSyncAbort?.abort?.();
} catch (e) {
console.debug?.(e);
}
try {
e?._mjrDiffAbort?.abort?.();
} catch (e) {
console.debug?.(e);
}
try {
e?._mjrSliderAbort?.abort?.();
} catch (e) {
console.debug?.(e);
}
try {
e._mjrDiffRequest = null;
} catch (e) {
console.debug?.(e);
}
try {
e._mjrSliderAbort = null;
} catch (e) {
console.debug?.(e);
}
if (!e || !t || !n) return;
let s = (e, t) => {
try {
let n = e?.querySelector?.(".mjr-viewer-audio-src") || e?.querySelector?.("audio");
if (!n) return;
let r = String(t || "").toUpperCase() === "A";
if (n.muted = !r, !r) try {
n.pause?.();
} catch (e) {
console.debug?.(e);
}
} catch (e) {
console.debug?.(e);
}
}, c = (() => {
try {
let e = String(t.abCompareMode || "wipe");
return e === "wipeH" ? "wipe" : e;
} catch {
return "wipe";
}
})(), l = c === "wipe" || c === "wipeV", u = c === "wipeV" ? "y" : "x", d = t.compareAsset || (Array.isArray(t.assets) && t.assets.length === 2 ? t.assets[1 - (t.currentIndex || 0)] : null) || n, f = (() => {
try {
return i?.(d);
} catch {
return "";
}
})(), p = document.createElement("div");
p.style.cssText = "\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n overflow: hidden;\n display: flex;\n align-items: center;\n justify-content: center;\n ";
let m = a?.(d, f);
m && p.appendChild(m);
try {
let e = m?.querySelector?.(".mjr-viewer-video-src") || m?.querySelector?.("video");
e?.dataset && (e.dataset.mjrCompareRole = "B");
let t = m?.querySelector?.(".mjr-viewer-audio-src") || m?.querySelector?.("audio");
t?.dataset && (t.dataset.mjrCompareRole = "B");
} catch (e) {
console.debug?.(e);
}
s(m, "B");
try {
let e = m?.querySelector?.("canvas.mjr-viewer-media") || (m instanceof HTMLCanvasElement ? m : null);
e?.dataset && (e.dataset.mjrCompareRole = "B");
} catch (e) {
console.debug?.(e);
}
let h = document.createElement("div");
h.style.cssText = "\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n overflow: hidden;\n display: flex;\n align-items: center;\n justify-content: center;\n ";
let g = (() => {
try {
return !!window.CSS?.supports?.("clip-path: inset(0 50% 0 0)");
} catch {
return !1;
}
})(), _ = (t) => {
try {
let n = Math.max(0, Math.min(100, Number(t) || 0));
if (g) {
let e = u === "y" ? `inset(0 0 ${100 - n}% 0)` : `inset(0 ${100 - n}% 0 0)`;
h.style.clipPath = e, h.style.webkitClipPath = e;
return;
}
let r = e.getBoundingClientRect(), i = r.width || 1, a = r.height || 1;
if (u === "y") {
let e = Math.round(a * n / 100);
h.style.clip = `rect(0px, ${i}px, ${e}px, 0px)`;
} else {
let e = Math.round(i * n / 100);
h.style.clip = `rect(0px, ${e}px, ${a}px, 0px)`;
}
} catch (e) {
console.debug?.(e);
}
}, v = (() => {
if (!l) return 100;
try {
let e = Number(t._abWipePercent);
if (Number.isFinite(e) && e >= 0 && e <= 100) return e;
} catch (e) {
console.debug?.(e);
}
return 50;
})();
_(v);
try {
l && (t._abWipePercent = v);
} catch (e) {
console.debug?.(e);
}
let y = a?.(n, r);
y && h.appendChild(y);
try {
let e = y?.querySelector?.(".mjr-viewer-video-src") || y?.querySelector?.("video");
e?.dataset && (e.dataset.mjrCompareRole = "A");
let t = y?.querySelector?.(".mjr-viewer-audio-src") || y?.querySelector?.("audio");
t?.dataset && (t.dataset.mjrCompareRole = "A");
} catch (e) {
console.debug?.(e);
}
s(y, "A");
try {
let e = y?.querySelector?.("canvas.mjr-viewer-media") || (y instanceof HTMLCanvasElement ? y : null);
e?.dataset && (e.dataset.mjrCompareRole = "A");
} catch (e) {
console.debug?.(e);
}
let b = document.createElement("div");
b.className = "mjr-ab-slider", b.style.cssText = "\n position: absolute;\n z-index: 10;\n touch-action: none;\n user-select: none;\n ";
let x = document.createElement("div");
x.style.cssText = "\n position: absolute;\n background: white;\n pointer-events: none;\n box-shadow: 0 0 4px rgba(0,0,0,0.5);\n ", b.appendChild(x);
let S = (e) => e === "multiply" || e === "screen" || e === "add";
try {
if (l) {
b.style.display = "";
try {
let e = y?.querySelector?.(".mjr-viewer-media") || y;
e && (e.style.mixBlendMode = "");
} catch (e) {
console.debug?.(e);
}
} else {
_(100), b.style.display = "none";
try {
h.style.opacity = "0", p.style.opacity = "0", h.style.pointerEvents = "none", p.style.pointerEvents = "none";
} catch (e) {
console.debug?.(e);
}
let t = document.createElement("div");
t.style.cssText = "\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n overflow: hidden;\n display: flex;\n align-items: center;\n justify-content: center;\n ";
let n = document.createElement("canvas");
n.className = "mjr-viewer-media";
try {
n.dataset.mjrCompareRole = "D";
} catch (e) {
console.debug?.(e);
}
n.style.cssText = "\n max-width: 100%;\n max-height: 100%;\n display: block;\n ", t.appendChild(n);
let r = (e) => {
try {
return e ? e instanceof HTMLCanvasElement ? e : e.querySelector?.("canvas.mjr-viewer-media") || e.querySelector?.("canvas") || null : null;
} catch {
return null;
}
}, i = r(y), a = r(m), o = (() => {
try {
return n.getContext("2d", { willReadFrequently: !0 });
} catch {
return null;
}
})(), s = () => {
try {
let e = i?.width || 0, t = i?.height || 0, r = a?.width || 0, o = a?.height || 0, s = Math.max(1, Math.min(e || r, r || e)), c = Math.max(1, Math.min(t || o, o || t));
return n.width !== s && (n.width = s), n.height !== c && (n.height = c), {
w: s,
h: c
};
} catch {
return {
w: 0,
h: 0
};
}
}, l = () => {
if (!o) return !1;
let { w: e, h: t } = s();
if (!(e > 1 && t > 1)) return !1;
let n = !1;
try {
o.save(), n = !0;
} catch (e) {
console.debug?.(e);
}
try {
try {
o.clearRect(0, 0, e, t);
} catch (e) {
console.debug?.(e);
}
let n = e * t, r = !!(y?.querySelector?.("video") || m?.querySelector?.("video")), s = (n) => {
try {
return o.globalCompositeOperation = "copy", o.drawImage(a, 0, 0, e, t), o.globalCompositeOperation = n, o.drawImage(i, 0, 0, e, t), o.globalCompositeOperation = "source-over", !0;
} catch {
return !1;
}
}, l = () => {
if (r || !(n > 0 && n <= 75e4)) return s("difference");
try {
o.globalCompositeOperation = "copy", o.drawImage(i, 0, 0, e, t);
let n = o.getImageData(0, 0, e, t);
o.clearRect(0, 0, e, t), o.globalCompositeOperation = "copy", o.drawImage(a, 0, 0, e, t);
let r = o.getImageData(0, 0, e, t), s = n.data, c = r.data;
for (let e = 0; e < c.length; e += 4) c[e] = Math.max(0, (c[e] || 0) - (s[e] || 0)), c[e + 1] = Math.max(0, (c[e + 1] || 0) - (s[e + 1] || 0)), c[e + 2] = Math.max(0, (c[e + 2] || 0) - (s[e + 2] || 0)), c[e + 3] = 255;
return o.putImageData(r, 0, 0), o.globalCompositeOperation = "source-over", !0;
} catch {
return s("difference");
}
};
if (S(c)) return s(c === "add" ? "lighter" : c);
if (c === "subtract") return l();
let u = () => {
if (r || !(n > 0 && n <= 1e6)) return s("difference");
try {
o.globalCompositeOperation = "copy", o.drawImage(i, 0, 0, e, t);
let n = o.getImageData(0, 0, e, t);
o.clearRect(0, 0, e, t), o.drawImage(a, 0, 0, e, t);
let r = o.getImageData(0, 0, e, t), s = n.data, c = r.data, l = 0;
for (let e = 0; e < s.length; e += 4) c[e] = Math.abs((s[e] || 0) - (c[e] || 0)), c[e + 1] = Math.abs((s[e + 1] || 0) - (c[e + 1] || 0)), c[e + 2] = Math.abs((s[e + 2] || 0) - (c[e + 2] || 0)), c[e + 3] = 255, c[e] > l && (l = c[e]), c[e + 1] > l && (l = c[e + 1]), c[e + 2] > l && (l = c[e + 2]);
if (l > 0 && l < 255) {
let e = 255 / l;
for (let t = 0; t < c.length; t += 4) c[t] = Math.min(255, Math.round(c[t] * e)), c[t + 1] = Math.min(255, Math.round(c[t + 1] * e)), c[t + 2] = Math.min(255, Math.round(c[t + 2] * e));
}
return o.putImageData(r, 0, 0), o.globalCompositeOperation = "source-over", !0;
} catch {
return s("difference");
}
};
if (c === "absdiff") return u();
if (!s("difference")) return !1;
if (c === "difference") try {
if (n > 0 && n <= 1e6) {
let n = o.getImageData(0, 0, e, t), r = n.data;
for (let e = 0; e < r.length; e += 4) r[e] = Math.min(255, (r[e] || 0) * 4), r[e + 1] = Math.min(255, (r[e + 1] || 0) * 4), r[e + 2] = Math.min(255, (r[e + 2] || 0) * 4), r[e + 3] = 255;
o.putImageData(n, 0, 0);
}
} catch (e) {
console.debug?.(e);
}
return !0;
} finally {
if (n) try {
o.restore();
} catch (e) {
console.debug?.(e);
}
}
};
try {
e.appendChild(t);
} catch (e) {
console.debug?.(e);
}
try {
let t = new AbortController();
e._mjrDiffAbort = t;
let n = 0, r = () => {
let e = performance.now();
return e - n < 83.33333333333333 ? !1 : (n = e, !0);
}, o = () => {
if (!t.signal.aborted) try {
requestAnimationFrame(() => {
if (!t.signal.aborted) try {
i && a && r() && l();
} catch (e) {
console.debug?.(e);
}
});
} catch (e) {
console.debug?.(e);
}
};
if (e._mjrDiffRequest = o, y?.querySelector?.("video") || m?.querySelector?.("video")) {
let e = null, n = y?.querySelector?.("video") || null, s = m?.querySelector?.("video") || null, c = () => {
try {
let e = n ? !n.paused : !1, t = s ? !s.paused : !1;
return e || t;
} catch {
return !0;
}
}, u = () => {
if (!t.signal.aborted) {
try {
i && a && r() && l();
} catch (e) {
console.debug?.(e);
}
if (!c()) e = null;
else try {
e = requestAnimationFrame(u);
} catch {
e = null;
}
}
}, d = () => {
if (!t.signal.aborted && e == null) try {
e = requestAnimationFrame(u);
} catch {
e = null;
}
};
try {
n && (n.addEventListener("play", d, {
signal: t.signal,
passive: !0
}), n.addEventListener("seeking", o, {
signal: t.signal,
passive: !0
}), n.addEventListener("seeked", o, {
signal: t.signal,
passive: !0
}), n.addEventListener("timeupdate", o, {
signal: t.signal,
passive: !0
}));
} catch (e) {
console.debug?.(e);
}
try {
s && (s.addEventListener("play", d, {
signal: t.signal,
passive: !0
}), s.addEventListener("seeking", o, {
signal: t.signal,
passive: !0
}), s.addEventListener("seeked", o, {
signal: t.signal,
passive: !0
}), s.addEventListener("timeupdate", o, {
signal: t.signal,
passive: !0
}));
} catch (e) {
console.debug?.(e);
}
d(), t.signal.addEventListener("abort", () => {
try {
e != null && cancelAnimationFrame(e);
} catch (e) {
console.debug?.(e);
}
}, { once: !0 });
} else o();
} catch (e) {
console.debug?.(e);
}
}
} catch (e) {
console.debug?.(e);
}
let C = !1, w = null, T = (e) => {
try {
let n = Math.max(0, Math.min(100, e));
_(n);
try {
t._abWipePercent = n;
} catch (e) {
console.debug?.(e);
}
u === "y" ? b.style.top = `${n}%` : b.style.left = `${n}%`;
} catch (e) {
console.debug?.(e);
}
}, E = (t) => {
if (C) try {
t.preventDefault(), t.stopPropagation(), w ||= e.getBoundingClientRect();
let n = 50;
n = u === "y" ? ((Number(t.clientY) || 0) - w.top) / w.height * 100 : ((Number(t.clientX) || 0) - w.left) / w.width * 100, T(n);
} catch (e) {
console.debug?.(e);
}
}, D = (e) => {
if (C) {
C = !1, w = null;
try {
b.releasePointerCapture(e.pointerId), b.style.cursor = u === "y" ? "ns-resize" : "ew-resize";
} catch (e) {
console.debug?.(e);
}
}
};
try {
let t = new AbortController();
e._mjrSliderAbort = t, b.addEventListener("pointerdown", (t) => {
if (t.button === 0) {
C = !0, t.preventDefault(), t.stopPropagation();
try {
w = e.getBoundingClientRect();
} catch (e) {
console.debug?.(e);
}
try {
b.setPointerCapture(t.pointerId), b.style.cursor = "grabbing";
} catch (e) {
console.debug?.(e);
}
E(t);
}
}, {
signal: t.signal,
passive: !1
}), window.addEventListener("pointermove", E, {
signal: t.signal,
passive: !1
}), window.addEventListener("pointerup", D, {
signal: t.signal,
passive: !0
}), window.addEventListener("pointercancel", D, {
signal: t.signal,
passive: !0
});
} catch (e) {
console.debug?.(e);
}
try {
u === "y" ? (b.style.width = "100%", b.style.height = "40px", b.style.left = "0", b.style.top = `${v}%`, b.style.transform = "translate(0, -50%)", b.style.cursor = "ns-resize", x.style.width = "100%", x.style.height = "1px", x.style.top = "50%", x.style.transform = "translate(0, -50%)") : (b.style.height = "100%", b.style.width = "40px", b.style.top = "0", b.style.left = `${v}%`, b.style.transform = "translate(-50%, 0)", b.style.cursor = "ew-resize", x.style.height = "100%", x.style.width = "1px", x.style.left = "50%", x.style.transform = "translate(-50%, 0)");
} catch (e) {
console.debug?.(e);
}
try {
e.appendChild(p), e.appendChild(h), e.appendChild(b);
} catch (e) {
console.debug?.(e);
}
}
export { e as renderABCompareView };
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
// ui/app/events.ts
var e = "mjr:asset-rating-changed", t = "mjr:asset-tags-changed", n = "mjr:viewer-info-refreshed", r = "mjr:viewer-active-asset-changed", i = Object.freeze({
ASSET_RATING_CHANGED: e,
ASSET_TAGS_CHANGED: t,
VIEWER_INFO_REFRESHED: n,
VIEWER_ACTIVE_ASSET_CHANGED: r,
SCAN_COMPLETE: "mjr-scan-complete",
CORE_EXECUTION_ASSETS_READY: "mjr-core-execution-assets-ready",
ENRICHMENT_STATUS: "mjr-enrichment-status",
DB_RESTORE_STATUS: "mjr-db-restore-status",
ASSETS_DELETED: "mjr:assets-deleted",
ASSETS_REMOVED: "mjr-assets-removed",
SETTINGS_CHANGED: "mjr-settings-changed",
SELECTION_CHANGED: "mjr:selection-changed",
RELOAD_GRID: "mjr:reload-grid",
AGENDA_STATUS: "MJR:AgendaStatus",
VERSION_UPDATE_AVAILABLE: "mjr:version-update-available",
MFV_OPEN: "mjr:mfv-open",
MFV_CLOSE: "mjr:mfv-close",
MFV_TOGGLE: "mjr:mfv-toggle",
MFV_LIVE_TOGGLE: "mjr:mfv-live-toggle",
MFV_PREVIEW_TOGGLE: "mjr:mfv-preview-toggle",
MFV_POPOUT: "mjr:mfv-popout",
MFV_VISIBILITY_CHANGED: "mjr:mfv-visibility-changed",
MFV_NODESTREAM_TOGGLE: "mjr:mfv-nodestream-toggle",
NEW_GENERATION_OUTPUT: "mjr:new-generation-output",
NODE_STREAM_OUTPUT: "mjr:node-stream-output",
ASSET_ADDED: "mjr:asset-added",
ASSET_INDEXING: "mjr.asset.indexing",
ASSET_INDEXED: "mjr.asset.indexed",
ASSET_INDEX_FAILED: "mjr.asset.index_failed",
SCAN_PROGRESS: "mjr.scan.progress",
RUNTIME_STATUS: "mjr.runtime.status",
WATCHER_STATUS: "mjr.watcher.status",
STRUCTURED_EVENT: "mjr.event",
OPEN_ASSETS_MANAGER: "mjr:open-assets-manager",
OPEN_VIEWER: "mjr:open-viewer",
OPEN_STACK_GROUP: "mjr:open-stack-group",
OPEN_NODE_CONTEXT: "mjr:open-node-context",
OPEN_MESSAGE_HISTORY: "mjr:open-message-history"
});
export { r as i, t as n, i as r, e as t };
+645
View File
@@ -0,0 +1,645 @@
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
import { An as t, Wt as n, c as r, t as i } from "./config-C9a-wT5M.js";
import { r as a } from "./events-1iwo5blr.js";
// ui/utils/logging.ts
function o(e, ...t) {
try {
i.DEBUG_VERBOSE_ERRORS && console.debug(e, ...t);
} catch {}
}
function s(e, t = "Majoor", { showToast: r = !1, toastType: a = "error" } = {}) {
let o = e?.message || String(e || "Unknown error");
try {
i.DEBUG_VERBOSE_ERRORS ? console.error(`[Majoor][${t}]`, o, e) : console.debug(`[Majoor][${t}]`, o);
} catch (e) {
console.debug?.(e);
}
if (r && i.DEBUG_VERBOSE_ERRORS) try {
n(`${t}: ${o}`, a, 4e3);
} catch (e) {
console.debug?.(e);
}
}
// ui/features/panel/panelRuntimeRefs.ts
var c = null;
function l(e) {
return e ? typeof e?.isConnected != "boolean" || e.isConnected : !1;
}
function u() {
try {
let e = document.getElementById("mjr-assets-grid") || document.querySelector(".mjr-grid");
if (l(e)) return e;
} catch (e) {
console.debug?.(e);
}
return null;
}
function d(e) {
return c = e || null, c;
}
function f(e = null) {
e && c !== e || (c = null);
}
function p() {
return l(c) ? c : (c = null, u());
}
// ui/features/grid/GridSelectionManager.ts
function m(e) {
let t = /* @__PURE__ */ new Set();
if (!e) return t;
try {
let n = e.dataset?.mjrSelectedAssetIds;
if (n) {
let e = JSON.parse(n);
if (Array.isArray(e)) for (let n of e) n != null && t.add(String(n));
return t;
}
} catch (e) {
console.warn("[MJR] selection state parse error:", e);
}
try {
let n = e.dataset?.mjrSelectedAssetId;
n && t.add(String(n));
} catch (e) {
console.debug?.(e);
}
return t;
}
function h(e, t, n = "") {
let r = Array.from(t || []), i = n ? String(n) : r[0] ? String(r[0]) : "";
try {
r.length ? (e.dataset.mjrSelectedAssetIds = JSON.stringify(r), i ? e.dataset.mjrSelectedAssetId = String(i) : delete e.dataset.mjrSelectedAssetId) : (delete e.dataset.mjrSelectedAssetIds, delete e.dataset.mjrSelectedAssetId);
} catch (e) {
console.debug?.(e);
}
return r;
}
function g(e, t, n) {
if (!e) return;
let r = t instanceof Set ? t : new Set(Array.from(t || []).map(String));
try {
let t = typeof n == "function" ? n(e) : [];
for (let e of t) {
let t = e?.dataset?.mjrAssetId;
t && r.has(String(t)) ? (e.classList.add("is-selected"), e.setAttribute("aria-selected", "true")) : (e.classList.remove("is-selected"), e.setAttribute("aria-selected", "false"));
}
} catch (e) {
console.debug?.(e);
}
}
function _(e, t, { activeId: n = "" } = {}, r) {
if (!e) return [];
let i = new Set(Array.from(t || []).map(String).filter(Boolean)), a = h(e, i, n);
g(e, i, r);
let o = {
selectedIds: a,
activeId: n || a[0] || ""
};
try {
e.dispatchEvent?.(new CustomEvent("mjr:selection-changed", { detail: o }));
} catch (e) {
console.debug?.(e);
}
try {
window.dispatchEvent(new CustomEvent("mjr:selection-changed", { detail: o }));
} catch (e) {
console.debug?.(e);
}
return a;
}
// ui/features/panel/controllers/hotkeysState.ts
var v = {
suspended: !1,
scope: null,
ratingHotkeysActive: !1
};
function ee() {
return v;
}
function te(e) {
v.scope = e == null ? null : String(e);
}
function ne() {
return !!v.suspended;
}
function re(e) {
v.ratingHotkeysActive = !!e;
}
// ui/features/viewer/viewerRuntimeHosts.ts
var y = null, ie = ".mjr-mfv";
function ae(e) {
return !!e && typeof e.appendChild == "function";
}
function b() {
return typeof document > "u" ? null : document?.body || null;
}
function oe() {
return typeof document > "u" ? null : document?.body || document?.documentElement || null;
}
function se(e) {
return ae(e) ? e === b() || typeof e?.isConnected != "boolean" || e.isConnected : !1;
}
function ce(e) {
return ae(e) ? e : null;
}
function x(e) {
return se(e) ? e : b();
}
function le(e) {
let t = oe();
return se(t) ? t : x(e);
}
function ue(e, t, n = x) {
let r = [], i = /* @__PURE__ */ new Set();
for (let a of [
n(t),
b(),
t
]) {
if (!a || i.has(a)) continue;
i.add(a);
let t = [];
try {
t = Array.from(a.querySelectorAll?.(e) || []);
} catch (e) {
console.debug?.(e);
}
for (let e of t) e && !r.includes(e) && r.push(e);
}
return r;
}
function de(e, t, n = x) {
let r = n(t);
if (!r) return;
let i = ue(e, t, n);
for (let e of i) if (e && e.parentNode !== r) try {
r.appendChild(e);
} catch (e) {
console.debug?.(e);
}
}
function fe(e) {
return y = ce(e), de(ie, y, le), () => pe(e);
}
function pe(e) {
e && y !== e || (y = null);
}
function me(e) {
let t = le(y);
try {
t?.appendChild?.(e);
} catch (e) {
console.debug?.(e);
}
return t;
}
// ui/features/viewer/floatingViewerManager.ts
var he = /* @__PURE__ */ e({
floatingViewerManager: () => X,
installFloatingViewerGlobalHandlers: () => $,
removeFloatingViewerGlobalHandlers: () => He,
teardownFloatingViewerManager: () => Ue
}), S = null, ge = null;
async function _e() {
return S || (ge ||= import("./FloatingViewer--x61GAzy.js").then((e) => (S = e.FloatingViewer, S)), ge);
}
var C = null, ve = null;
async function ye() {
if (!C) return ve ||= import("./NodeStreamController-CIKDDXJT.js").then((e) => {
C = e.setNodeStreamActive;
}), ve;
}
var w = Object.freeze({
SIMPLE: "simple",
AB: "ab",
SIDE: "side",
GRID: "grid",
GRAPH: "graph"
});
function be(e) {
let t = String(e || "").trim().toLowerCase();
return t === "sidebyside" ? w.SIDE : Object.values(w).includes(t) ? t : "";
}
var T = null;
function E() {
return i.MFV_LIVE_DEFAULT !== !1;
}
function D() {
return i.MFV_PREVIEW_DEFAULT !== !1;
}
var O = E(), k = D(), A = !1, j = null, M = !1, N = null, P = 0;
function xe() {
O = E(), k = D(), T?.setLiveActive(O), T?.setPreviewActive(k);
}
async function F() {
if (!T) {
let e = await _e();
T || (T = new e({ controller: {
close: () => X.close(),
toggle: () => X.toggle(),
toggleLive: () => X.toggleLive(),
togglePreview: () => X.togglePreview(),
toggleNodeStream: () => X.toggleNodeStream(),
popOut: () => X.popOut(),
onModeChanged: (e) => {
T?.isVisible && e !== w.SIMPLE && U();
},
handleForwardedKeydown: (e) => Q(e, { forwarded: !0 })
} }), me(T.render()));
}
try {
let e = T?.element || null;
e?.isConnected === !1 && me(e);
} catch (e) {
console.debug?.(e);
}
return T;
}
function I() {
try {
N?.abort();
} catch (e) {
console.debug?.(e);
}
N = null;
}
function L() {
try {
let e = window.__MJR_LAST_SELECTION_GRID__;
if (e?.isConnected) return e;
} catch (e) {
console.debug?.(e);
}
return p();
}
function Se() {
if (T) {
try {
T.dispose?.();
} catch (e) {
console.debug?.(e);
}
T = null;
}
}
function R(e) {
typeof window < "u" && window.dispatchEvent(new CustomEvent(a.MFV_VISIBILITY_CHANGED, { detail: { visible: !!e } }));
}
function z(e) {
e && (e.setLiveActive(O), e.setPreviewActive(k), e.setNodeStreamActive?.(A));
}
function B(e) {
e?.setNodeStreamSelection && e.setNodeStreamSelection(j || null);
}
function V(e, t) {
if (!e) return;
let n = e._mode;
if (n !== w.AB && n !== w.SIDE && n !== w.GRID) {
e.loadMediaA(t, { autoMode: !0 });
return;
}
let r = e.getPinnedSlots();
if (n === w.GRID) {
let n = [
"A",
"B",
"C",
"D"
].find((e) => !r.has(e));
if (!n) return;
let i = {
A: e._mediaA,
B: e._mediaB,
C: e._mediaC,
D: e._mediaD
};
i[n] = t, e.loadMediaQuad(i.A, i.B, i.C, i.D);
return;
}
let i = r.has("A"), a = r.has("B");
i && a || (a ? e.loadMediaPair(t, e._mediaB) : e.loadMediaPair(e._mediaA, t));
}
function Ce(e) {
try {
let t = L();
if (!t) return null;
let n = Array.from(t.querySelectorAll("[data-mjr-asset-id]")), r = n.findIndex((t) => t.dataset.mjrAssetId === String(e));
if (r < 0) return null;
let i = (n[r + 1] ?? n[r - 1] ?? null)?.dataset?.mjrAssetId ?? null;
return i && i !== String(e) ? i : null;
} catch (e) {
return console.debug?.("[MFV] _findAdjacentGridId error", e), null;
}
}
async function H(e, t = []) {
if (!e.length || !T) return;
I();
let n = ++P, i = typeof AbortController < "u" ? new AbortController() : null;
N = i;
try {
let a = T.getPinnedSlots(), o = a.size > 0, s = T._mode, c = s === w.GRID, l = s === w.AB || s === w.SIDE, u = c ? 4 : 2, d = e.slice(0, u);
if (o && (l || c)) {
let e = u - a.size;
d = d.slice(0, Math.max(1, e));
} else if (d.length === 1 && l) {
let e = Ce(d[0]);
e && (d = [d[0], e]);
}
let f = L(), p = f?._mjrGetAssets?.() || f?._mjrGetSelectedAssets?.() || [], m = new Map([...p, ...t].filter((e) => e && e.kind !== "folder").map((e) => [String(e.id), e])), h = d.filter((e) => Number.isSafeInteger(Number(e)) && Number(e) > 0), g = h.length ? await r(h, i ? { signal: i.signal } : {}) : {
ok: !0,
data: []
};
if (i?.signal.aborted || P !== n || !T) return;
let _ = new Map((g?.ok && Array.isArray(g.data) ? g.data : []).map((e) => [String(e.id), e])), v = d.map((e) => _.get(String(e)) || m.get(String(e))).filter(Boolean);
if (!v.length) return;
if (c) {
if (o) {
let e = {
A: T._mediaA,
B: T._mediaB,
C: T._mediaC,
D: T._mediaD
}, t = [
"A",
"B",
"C",
"D"
].filter((e) => !a.has(e)), n = 0;
for (let r of t) n < v.length && (e[r] = v[n++]);
T.loadMediaQuad(e.A, e.B, e.C, e.D);
} else v.length >= 3 ? T.loadMediaQuad(v[0], v[1], v[2], v[3] || null) : v.length >= 2 ? T.loadMediaPair(v[0], v[1]) : T.loadMediaA(v[0], { autoMode: !0 });
return;
}
if (a.has("A") && a.has("B") && T._mediaA && T._mediaB) return;
a.has("A") && T._mediaA ? T.loadMediaPair(T._mediaA, v[0]) : a.has("B") && T._mediaB ? T.loadMediaPair(v[0], T._mediaB) : d.length >= 2 && v.length >= 2 ? T.loadMediaPair(v[0], v[1]) : T.loadMediaA(v[0], { autoMode: !0 });
} catch (e) {
e?.name !== "AbortError" && s(e, "floatingViewerManager._loadFromIds");
} finally {
N === i && (N = null);
}
}
function U() {
try {
let e = L();
if (!e) return;
let t = m(e);
if (!t.size) return;
H(Array.from(t));
} catch (e) {
console.debug?.("[MFV] Error reading current grid selection", e);
}
}
function we(e) {
if (!T?.isVisible || T._mode === w.GRAPH) return;
let t = Array.isArray(e?.detail?.selectedAssets) ? e.detail.selectedAssets : [], n = new Set(t.filter((e) => String(e?.kind || "").toLowerCase() === "folder").map((e) => String(e?.id || "")).filter(Boolean)), r = Array.isArray(e?.detail?.selectedIds) ? e.detail.selectedIds.map(String).filter((e) => !!e && !n.has(e)) : [];
if (r.length) H(r, t);
else try {
let e = L();
if (!e) return;
let t = Array.from(m(e)).map(String).filter(Boolean);
if (!t.length) return;
H(t);
} catch (e) {
console.debug?.("[MFV] selection fallback failed", e);
}
}
function W() {
M || typeof window < "u" && (window.addEventListener(a.SELECTION_CHANGED, we), M = !0);
}
function Te() {
typeof window < "u" && window.removeEventListener(a.SELECTION_CHANGED, we), M = !1, I();
}
var G = !1, K = null, q = null, J = "";
function Ee() {
T?.isVisible && T.refreshSidebar?.();
}
function De() {
Oe();
let e = typeof window < "u" ? window : globalThis;
typeof e.requestAnimationFrame == "function" ? (J = "raf", q = e.requestAnimationFrame(() => {
q = null, J = "", Ee();
})) : (J = "timeout", q = e.setTimeout(() => {
q = null, J = "", Ee();
}, 16));
}
function Oe() {
if (q == null) return;
let e = typeof window < "u" ? window : globalThis;
try {
J === "raf" && typeof e.cancelAnimationFrame == "function" ? e.cancelAnimationFrame(q) : typeof e.clearTimeout == "function" && e.clearTimeout(q);
} catch (e) {
console.debug?.(e);
}
q = null, J = "";
}
function Y() {
if (!G) try {
K = t(De, { includePointerFallback: !0 }), G = typeof K == "function";
} catch (e) {
console.debug?.("[MFV] _bindNodeSelectionListener error", e);
}
}
function ke() {
if (G) {
Oe();
try {
K?.();
} catch (e) {
console.debug?.("[MFV] _unbindNodeSelectionListener error", e);
}
K = null, G = !1;
}
}
var X = {
isGraphModeVisible() {
return !!(T?.isVisible && T?._mode === w.GRAPH);
},
async openAssets({ assets: e = [], asset: t = null, index: n = 0, mode: r = "" } = {}) {
let i = Array.isArray(e) ? e.filter(Boolean) : t ? [t] : [];
if (!i.length) return !1;
let a = await F(), o = !!a.isVisible, s = Math.max(0, Math.min(Number(n) || 0, i.length - 1)), c = be(r);
c && a.setMode(c), a.show(), z(a), W(), Y();
let l = a._mode;
return l === w.GRID && i.length >= 3 ? a.loadMediaQuad(i[0], i[1], i[2], i[3] || null) : (l === w.AB || l === w.SIDE) && i.length >= 2 ? a.loadMediaPair(i[0], i[1]) : a.loadMediaA(i[s], { autoMode: !1 }), o || R(!0), !0;
},
async open() {
let e = await F();
e.show(), z(e), W(), Y(), G || requestAnimationFrame(() => Y()), U(), A && B(e), R(!0);
},
close() {
if (T) try {
T.isPopped && T.popIn(), T.hide();
} catch (e) {
console.debug?.(e);
}
Te(), T?.setNodeStreamSelection?.(null), ke(), R(!1);
},
async toggle() {
T?.isVisible ? X.close() : await X.open();
},
toggleLive() {
X.setLiveActive(!O);
},
togglePreview() {
X.setPreviewActive(!k);
},
async toggleCompareAB() {
let e = await F();
if (!e.isVisible) {
e.setMode(w.AB), e.show(), z(e), W(), U(), R(!0);
return;
}
let t = {
[w.AB]: w.SIDE,
[w.SIDE]: w.SIMPLE,
[w.GRID]: w.SIMPLE,
[w.SIMPLE]: w.AB
}[e._mode] || w.AB;
e.setMode(t), t !== w.SIMPLE && U();
},
async upsertWithContent(e) {
let t = await F(), n = !!t.isVisible;
(n || i.MFV_LIVE_AUTO_OPEN !== !1) && (t.show(), z(t), W(), V(t, e), n || R(!0));
},
setLiveActive(e) {
O = !!e, T?.setLiveActive(O);
},
getLiveActive() {
return O;
},
async popOut() {
let e = await F();
e.isPopped ? e.popIn() : (e.isVisible || await X.open(), e.popOut());
},
setPreviewActive(e) {
k = !!e, T?.setPreviewActive(k);
},
getPreviewActive() {
return k;
},
canAcceptPreviewBlob() {
return k ? T?.isVisible ? !0 : i.MFV_PREVIEW_AUTO_OPEN !== !1 : !1;
},
async feedPreviewBlob(e, t = {}) {
if (!X.canAcceptPreviewBlob()) return;
let n = await F(), r = !!n.isVisible;
(r || i.MFV_PREVIEW_AUTO_OPEN !== !1) && (n.isVisible || n.show(), z(n), n.loadPreviewBlob(e, ...Object.keys(t).length ? [t] : []), r || R(!0));
},
toggleNodeStream() {
X.setNodeStreamActive(!A);
},
setNodeStreamActive(e) {
A = !!e, A || (j = null), ye().then(() => {
C && C(A);
}), T?.setNodeStreamActive?.(A), A ? T && B(T) : T?.setNodeStreamSelection?.(null);
},
getNodeStreamActive() {
return A;
},
setNodeStreamSelection(e, t, n) {
j = e == null || e === "" ? null : {
nodeId: e,
classType: t,
title: n
};
let r = T;
r && B(r);
},
async feedNodeStream(e) {
if (!A) return;
let t = await F(), n = !!t.isVisible;
(n || i.MFV_NODE_STREAM_AUTO_OPEN !== !1) && (t.isVisible || (t.show(), W()), z(t), B(t), V(t, e), n || R(!0));
}
}, Z = !1, Ae = () => X.open(), je = () => X.close(), Me = () => X.toggle(), Ne = () => X.toggleLive(), Pe = () => X.togglePreview(), Fe = () => X.toggleNodeStream(), Ie = () => X.popOut(), Le = () => {
try {
T?.isPopped && T.popIn();
} catch {}
}, Re = (e) => {
let t = String(e?.detail?.key || "");
t && t !== "viewer" && t !== "viewer.mfvLiveDefault" && t !== "viewer.mfvPreviewDefault" || xe();
};
function ze(e) {
let t = String(e?.key || "");
return t === " " || t === "Spacebar" || t === "ArrowLeft" || t === "ArrowRight";
}
function Be(e) {
if (!T?.isVisible || !ze(e)) return !1;
try {
let t = e?.target, n = T?.element || null, r = !!n?.contains?.(t), i = (t?.closest?.(".mjr-mfv-simple-player") || (r || t == null ? n?.querySelector?.(".mjr-mfv-simple-player") : null))?._mjrSimplePlayerHandleKeydown;
if (typeof i == "function") return i(e), !!e?.defaultPrevented;
let a = (t?.closest?.(".mjr-mfv-player-host") || (r || t == null ? n?.querySelector?.(".mjr-mfv-player-host") : null))?._mjrMediaControlsHandle;
if (a && typeof a == "object") {
let t = String(e?.key || ""), n = () => {
e.preventDefault?.(), e.stopPropagation?.(), e.stopImmediatePropagation?.();
};
if ((t === " " || t === "Spacebar") && typeof a.togglePlay == "function") return a.togglePlay(), n(), !0;
if (t === "ArrowLeft" && typeof a.stepFrames == "function") return a.stepFrames(-1), n(), !0;
if (t === "ArrowRight" && typeof a.stepFrames == "function") return a.stepFrames(1), n(), !0;
}
return !1;
} catch (e) {
return console.debug?.(e), !1;
}
}
function Ve(e, t) {
if (t) return !0;
let n = T?.element;
if (!n) return !1;
try {
if (e?.target && n.contains?.(e.target)) return !0;
let t = typeof document < "u" ? document.activeElement : null;
return t && t !== document.body && n.contains?.(t) ? !0 : !!n.matches?.(":hover");
} catch {
return !1;
}
}
var Q = (e, { forwarded: t = !1 } = {}) => {
if (!T?.isVisible || ne() || ee().scope === "viewer") return;
let n = e?.key?.toLowerCase?.() || "", r = e?.target?.isContentEditable || e?.target?.closest?.("input, textarea, select, [contenteditable='true']");
if (r && Be(e) || r || Be(e)) return;
let i = () => {
e.preventDefault?.(), e.stopPropagation?.(), e.stopImmediatePropagation?.();
};
if (!e?.ctrlKey && !e?.metaKey && !e?.altKey && !e?.shiftKey) {
if (!Ve(e, t)) return;
if (n === "v") {
i(), X.toggle();
return;
}
if (n === "k") {
i(), X.togglePreview();
return;
}
if (n === "l") {
i(), X.toggleLive();
return;
}
if (n === "n") {
i(), X.toggleNodeStream();
return;
}
n === "c" && (i(), X.toggleCompareAB());
}
};
function $() {
Z || typeof window < "u" && window?.addEventListener && (window.addEventListener(a.MFV_OPEN, Ae), window.addEventListener(a.MFV_CLOSE, je), window.addEventListener(a.MFV_TOGGLE, Me), window.addEventListener(a.MFV_LIVE_TOGGLE, Ne), window.addEventListener(a.MFV_PREVIEW_TOGGLE, Pe), window.addEventListener(a.MFV_NODESTREAM_TOGGLE, Fe), window.addEventListener(a.MFV_POPOUT, Ie), window.addEventListener(a.SETTINGS_CHANGED, Re), window.addEventListener("keydown", Q, !0), window.addEventListener("beforeunload", Le), Z = !0);
}
function He() {
typeof window > "u" || !window?.removeEventListener || (window.removeEventListener(a.MFV_OPEN, Ae), window.removeEventListener(a.MFV_CLOSE, je), window.removeEventListener(a.MFV_TOGGLE, Me), window.removeEventListener(a.MFV_LIVE_TOGGLE, Ne), window.removeEventListener(a.MFV_PREVIEW_TOGGLE, Pe), window.removeEventListener(a.MFV_NODESTREAM_TOGGLE, Fe), window.removeEventListener(a.MFV_POPOUT, Ie), window.removeEventListener(a.SETTINGS_CHANGED, Re), window.removeEventListener("keydown", Q, !0), window.removeEventListener("beforeunload", Le)), Z = !1;
}
function Ue({ reinstallGlobalHandlers: e = !1 } = {}) {
let t = !!T?.isVisible;
try {
T?.isPopped && T.popIn();
} catch (e) {
console.debug?.(e);
}
He(), Te(), ke(), I(), P += 1, O = E(), k = D(), A = !1;
try {
C && C(!1);
} catch (e) {
console.debug?.(e);
}
Se(), t && R(!1), e && $();
}
export { s as _, fe as a, te as c, h as d, _ as f, o as g, d as h, Ue as i, re as l, p as m, he as n, ee as o, f as p, $ as r, ne as s, X as t, m as u };
-527
View File
@@ -1,527 +0,0 @@
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
import { _ as t, d as n, o as r, s as i, t as a } from "./viewerRuntimeHosts-n0pKLECw.js";
import { o, r as s, z as c } from "./events-C65wdOHm.js";
// ui/features/panel/panelRuntimeRefs.ts
var l = null;
function u(e) {
return e ? typeof e?.isConnected != "boolean" || e.isConnected : !1;
}
function d() {
try {
let e = document.getElementById("mjr-assets-grid") || document.querySelector(".mjr-grid");
if (u(e)) return e;
} catch (e) {
console.debug?.(e);
}
return null;
}
function f(e) {
return l = e || null, l;
}
function p(e = null) {
e && l !== e || (l = null);
}
function m() {
return u(l) ? l : (l = null, d());
}
// ui/features/grid/GridSelectionManager.ts
function h(e) {
let t = /* @__PURE__ */ new Set();
if (!e) return t;
try {
let n = e.dataset?.mjrSelectedAssetIds;
if (n) {
let e = JSON.parse(n);
if (Array.isArray(e)) for (let n of e) n != null && t.add(String(n));
return t;
}
} catch (e) {
console.warn("[MJR] selection state parse error:", e);
}
try {
let n = e.dataset?.mjrSelectedAssetId;
n && t.add(String(n));
} catch (e) {
console.debug?.(e);
}
return t;
}
function g(e, t, n = "") {
let r = Array.from(t || []), i = n ? String(n) : r[0] ? String(r[0]) : "";
try {
r.length ? (e.dataset.mjrSelectedAssetIds = JSON.stringify(r), i ? e.dataset.mjrSelectedAssetId = String(i) : delete e.dataset.mjrSelectedAssetId) : (delete e.dataset.mjrSelectedAssetIds, delete e.dataset.mjrSelectedAssetId);
} catch (e) {
console.debug?.(e);
}
return r;
}
function ee(e, t, n) {
if (!e) return;
let r = t instanceof Set ? t : new Set(Array.from(t || []).map(String));
try {
let t = typeof n == "function" ? n(e) : [];
for (let e of t) {
let t = e?.dataset?.mjrAssetId;
t && r.has(String(t)) ? (e.classList.add("is-selected"), e.setAttribute("aria-selected", "true")) : (e.classList.remove("is-selected"), e.setAttribute("aria-selected", "false"));
}
} catch (e) {
console.debug?.(e);
}
}
function _(e, t, { activeId: n = "" } = {}, r) {
if (!e) return [];
let i = new Set(Array.from(t || []).map(String).filter(Boolean)), a = g(e, i, n);
ee(e, i, r);
let o = {
selectedIds: a,
activeId: n || a[0] || ""
};
try {
e.dispatchEvent?.(new CustomEvent("mjr:selection-changed", { detail: o }));
} catch (e) {
console.debug?.(e);
}
try {
window.dispatchEvent(new CustomEvent("mjr:selection-changed", { detail: o }));
} catch (e) {
console.debug?.(e);
}
return a;
}
// ui/features/viewer/floatingViewerManager.ts
var te = /* @__PURE__ */ e({
floatingViewerManager: () => Y,
installFloatingViewerGlobalHandlers: () => $,
removeFloatingViewerGlobalHandlers: () => Ce,
teardownFloatingViewerManager: () => we
}), v = null, y = null;
async function ne() {
return v || (y ||= import("./FloatingViewer-BiEeZ0CS.js").then((e) => (v = e.FloatingViewer, v)), y);
}
var b = null, re = null;
async function ie() {
if (!b) return re ||= import("./NodeStreamController-CIKDDXJT.js").then((e) => {
b = e.setNodeStreamActive;
}), re;
}
var x = Object.freeze({
SIMPLE: "simple",
AB: "ab",
SIDE: "side",
GRID: "grid",
GRAPH: "graph"
});
function ae(e) {
let t = String(e || "").trim().toLowerCase();
return t === "sidebyside" ? x.SIDE : Object.values(x).includes(t) ? t : "";
}
var S = null;
function C() {
return o.MFV_LIVE_DEFAULT !== !1;
}
function w() {
return o.MFV_PREVIEW_DEFAULT !== !1;
}
var T = C(), E = w(), D = !1, O = null, k = !1, A = null, j = 0;
function oe() {
T = C(), E = w(), S?.setLiveActive(T), S?.setPreviewActive(E);
}
async function M() {
if (!S) {
let e = await ne();
S || (S = new e({ controller: {
close: () => Y.close(),
toggle: () => Y.toggle(),
toggleLive: () => Y.toggleLive(),
togglePreview: () => Y.togglePreview(),
toggleNodeStream: () => Y.toggleNodeStream(),
popOut: () => Y.popOut(),
onModeChanged: (e) => {
S?.isVisible && e !== x.SIMPLE && z();
},
handleForwardedKeydown: (e) => Q(e)
} }), a(S.render()));
}
try {
let e = S?.element || null;
e?.isConnected === !1 && a(e);
} catch (e) {
console.debug?.(e);
}
return S;
}
function N() {
try {
A?.abort();
} catch (e) {
console.debug?.(e);
}
A = null;
}
function P() {
try {
let e = window.__MJR_LAST_SELECTION_GRID__;
if (e?.isConnected) return e;
} catch (e) {
console.debug?.(e);
}
return m();
}
function se() {
if (S) {
try {
S.dispose?.();
} catch (e) {
console.debug?.(e);
}
S = null;
}
}
function F(e) {
typeof window < "u" && window.dispatchEvent(new CustomEvent(s.MFV_VISIBILITY_CHANGED, { detail: { visible: !!e } }));
}
function I(e) {
e && (e.setLiveActive(T), e.setPreviewActive(E), e.setNodeStreamActive?.(D));
}
function L(e) {
e?.setNodeStreamSelection && e.setNodeStreamSelection(O || null);
}
function ce(e, t) {
if (!e) return;
let n = e._mode;
if (n !== x.AB && n !== x.SIDE && n !== x.GRID) {
e.loadMediaA(t, { autoMode: !0 });
return;
}
let r = e.getPinnedSlots();
if (n === x.GRID) {
let n = [
"A",
"B",
"C",
"D"
].find((e) => !r.has(e));
if (!n) return;
let i = {
A: e._mediaA,
B: e._mediaB,
C: e._mediaC,
D: e._mediaD
};
i[n] = t, e.loadMediaQuad(i.A, i.B, i.C, i.D);
return;
}
let i = r.has("A"), a = r.has("B");
i && a || (a ? e.loadMediaPair(t, e._mediaB) : e.loadMediaPair(e._mediaA, t));
}
function le(e) {
try {
let t = P();
if (!t) return null;
let n = Array.from(t.querySelectorAll("[data-mjr-asset-id]")), r = n.findIndex((t) => t.dataset.mjrAssetId === String(e));
if (r < 0) return null;
let i = (n[r + 1] ?? n[r - 1] ?? null)?.dataset?.mjrAssetId ?? null;
return i && i !== String(e) ? i : null;
} catch (e) {
return console.debug?.("[MFV] _findAdjacentGridId error", e), null;
}
}
async function R(e, r = []) {
if (!e.length || !S) return;
N();
let i = ++j, a = typeof AbortController < "u" ? new AbortController() : null;
A = a;
try {
let n = S.getPinnedSlots(), o = n.size > 0, s = S._mode, c = s === x.GRID, l = s === x.AB || s === x.SIDE, u = c ? 4 : 2, d = e.slice(0, u);
if (o && (l || c)) {
let e = u - n.size;
d = d.slice(0, Math.max(1, e));
} else if (d.length === 1 && l) {
let e = le(d[0]);
e && (d = [d[0], e]);
}
let f = P(), p = f?._mjrGetAssets?.() || f?._mjrGetSelectedAssets?.() || [], m = new Map([...p, ...r].filter((e) => e && e.kind !== "folder").map((e) => [String(e.id), e])), h = d.filter((e) => Number.isSafeInteger(Number(e)) && Number(e) > 0), g = h.length ? await t(h, a ? { signal: a.signal } : {}) : {
ok: !0,
data: []
};
if (a?.signal.aborted || j !== i || !S) return;
let ee = new Map((g?.ok && Array.isArray(g.data) ? g.data : []).map((e) => [String(e.id), e])), _ = d.map((e) => ee.get(String(e)) || m.get(String(e))).filter(Boolean);
if (!_.length) return;
if (c) {
if (o) {
let e = {
A: S._mediaA,
B: S._mediaB,
C: S._mediaC,
D: S._mediaD
}, t = [
"A",
"B",
"C",
"D"
].filter((e) => !n.has(e)), r = 0;
for (let n of t) r < _.length && (e[n] = _[r++]);
S.loadMediaQuad(e.A, e.B, e.C, e.D);
} else _.length >= 3 ? S.loadMediaQuad(_[0], _[1], _[2], _[3] || null) : _.length >= 2 ? S.loadMediaPair(_[0], _[1]) : S.loadMediaA(_[0], { autoMode: !0 });
return;
}
if (n.has("A") && n.has("B") && S._mediaA && S._mediaB) return;
n.has("A") && S._mediaA ? S.loadMediaPair(S._mediaA, _[0]) : n.has("B") && S._mediaB ? S.loadMediaPair(_[0], S._mediaB) : d.length >= 2 && _.length >= 2 ? S.loadMediaPair(_[0], _[1]) : S.loadMediaA(_[0], { autoMode: !0 });
} catch (e) {
e?.name !== "AbortError" && n(e, "floatingViewerManager._loadFromIds");
} finally {
A === a && (A = null);
}
}
function z() {
try {
let e = P();
if (!e) return;
let t = h(e);
if (!t.size) return;
R(Array.from(t));
} catch (e) {
console.debug?.("[MFV] Error reading current grid selection", e);
}
}
function B(e) {
if (!S?.isVisible || S._mode === x.GRAPH) return;
let t = Array.isArray(e?.detail?.selectedAssets) ? e.detail.selectedAssets : [], n = new Set(t.filter((e) => String(e?.kind || "").toLowerCase() === "folder").map((e) => String(e?.id || "")).filter(Boolean)), r = Array.isArray(e?.detail?.selectedIds) ? e.detail.selectedIds.map(String).filter((e) => !!e && !n.has(e)) : [];
if (r.length) R(r, t);
else try {
let e = P();
if (!e) return;
let t = Array.from(h(e)).map(String).filter(Boolean);
if (!t.length) return;
R(t);
} catch (e) {
console.debug?.("[MFV] selection fallback failed", e);
}
}
function V() {
k || typeof window < "u" && (window.addEventListener(s.SELECTION_CHANGED, B), k = !0);
}
function H() {
typeof window < "u" && window.removeEventListener(s.SELECTION_CHANGED, B), k = !1, N();
}
var U = !1, W = null, G = null, K = "";
function q() {
S?.isVisible && S.refreshSidebar?.();
}
function ue() {
de();
let e = typeof window < "u" ? window : globalThis;
typeof e.requestAnimationFrame == "function" ? (K = "raf", G = e.requestAnimationFrame(() => {
G = null, K = "", q();
})) : (K = "timeout", G = e.setTimeout(() => {
G = null, K = "", q();
}, 16));
}
function de() {
if (G == null) return;
let e = typeof window < "u" ? window : globalThis;
try {
K === "raf" && typeof e.cancelAnimationFrame == "function" ? e.cancelAnimationFrame(G) : typeof e.clearTimeout == "function" && e.clearTimeout(G);
} catch (e) {
console.debug?.(e);
}
G = null, K = "";
}
function J() {
if (!U) try {
W = c(ue, { includePointerFallback: !0 }), U = typeof W == "function";
} catch (e) {
console.debug?.("[MFV] _bindNodeSelectionListener error", e);
}
}
function fe() {
if (U) {
de();
try {
W?.();
} catch (e) {
console.debug?.("[MFV] _unbindNodeSelectionListener error", e);
}
W = null, U = !1;
}
}
var Y = {
isGraphModeVisible() {
return !!(S?.isVisible && S?._mode === x.GRAPH);
},
async openAssets({ assets: e = [], asset: t = null, index: n = 0, mode: r = "" } = {}) {
let i = Array.isArray(e) ? e.filter(Boolean) : t ? [t] : [];
if (!i.length) return !1;
let a = await M(), o = !!a.isVisible, s = Math.max(0, Math.min(Number(n) || 0, i.length - 1)), c = ae(r);
c && a.setMode(c), a.show(), I(a), V(), J();
let l = a._mode;
return l === x.GRID && i.length >= 3 ? a.loadMediaQuad(i[0], i[1], i[2], i[3] || null) : (l === x.AB || l === x.SIDE) && i.length >= 2 ? a.loadMediaPair(i[0], i[1]) : a.loadMediaA(i[s], { autoMode: !1 }), o || F(!0), !0;
},
async open() {
let e = await M();
e.show(), I(e), V(), J(), U || requestAnimationFrame(() => J()), z(), D && L(e), F(!0);
},
close() {
if (S) try {
S.isPopped && S.popIn(), S.hide();
} catch (e) {
console.debug?.(e);
}
H(), S?.setNodeStreamSelection?.(null), fe(), F(!1);
},
async toggle() {
S?.isVisible ? Y.close() : await Y.open();
},
toggleLive() {
Y.setLiveActive(!T);
},
togglePreview() {
Y.setPreviewActive(!E);
},
async toggleCompareAB() {
let e = await M();
if (!e.isVisible) {
e.setMode(x.AB), e.show(), I(e), V(), z(), F(!0);
return;
}
let t = {
[x.AB]: x.SIDE,
[x.SIDE]: x.SIMPLE,
[x.GRID]: x.SIMPLE,
[x.SIMPLE]: x.AB
}[e._mode] || x.AB;
e.setMode(t), t !== x.SIMPLE && z();
},
async upsertWithContent(e) {
let t = await M(), n = !!t.isVisible;
(n || o.MFV_LIVE_AUTO_OPEN !== !1) && (t.show(), I(t), V(), ce(t, e), n || F(!0));
},
setLiveActive(e) {
T = !!e, S?.setLiveActive(T);
},
getLiveActive() {
return T;
},
async popOut() {
let e = await M();
e.isPopped ? e.popIn() : (e.isVisible || await Y.open(), e.popOut());
},
setPreviewActive(e) {
E = !!e, S?.setPreviewActive(E);
},
getPreviewActive() {
return E;
},
canAcceptPreviewBlob() {
return E ? S?.isVisible ? !0 : o.MFV_PREVIEW_AUTO_OPEN !== !1 : !1;
},
async feedPreviewBlob(e, t = {}) {
if (!Y.canAcceptPreviewBlob()) return;
let n = await M(), r = !!n.isVisible;
(r || o.MFV_PREVIEW_AUTO_OPEN !== !1) && (n.isVisible || n.show(), I(n), n.loadPreviewBlob(e, ...Object.keys(t).length ? [t] : []), r || F(!0));
},
toggleNodeStream() {
Y.setNodeStreamActive(!D);
},
setNodeStreamActive(e) {
D = !!e, D || (O = null), ie().then(() => {
b && b(D);
}), S?.setNodeStreamActive?.(D), D ? S && L(S) : S?.setNodeStreamSelection?.(null);
},
getNodeStreamActive() {
return D;
},
setNodeStreamSelection(e, t, n) {
O = e == null || e === "" ? null : {
nodeId: e,
classType: t,
title: n
};
let r = S;
r && L(r);
},
async feedNodeStream(e) {
if (!D) return;
let t = await M(), n = !!t.isVisible;
(n || o.MFV_NODE_STREAM_AUTO_OPEN !== !1) && (t.isVisible || (t.show(), V()), I(t), L(t), ce(t, e), n || F(!0));
}
}, X = !1, pe = () => Y.open(), me = () => Y.close(), he = () => Y.toggle(), Z = () => Y.toggleLive(), ge = () => Y.togglePreview(), _e = () => Y.toggleNodeStream(), ve = () => Y.popOut(), ye = () => {
try {
S?.isPopped && S.popIn();
} catch {}
}, be = (e) => {
let t = String(e?.detail?.key || "");
t && t !== "viewer" && t !== "viewer.mfvLiveDefault" && t !== "viewer.mfvPreviewDefault" || oe();
};
function xe(e) {
let t = String(e?.key || "");
return t === " " || t === "Spacebar" || t === "ArrowLeft" || t === "ArrowRight";
}
function Se(e) {
if (!S?.isVisible || !xe(e)) return !1;
try {
let t = e?.target, n = S?.element || null, r = !!n?.contains?.(t), i = (t?.closest?.(".mjr-mfv-simple-player") || (r || t == null ? n?.querySelector?.(".mjr-mfv-simple-player") : null))?._mjrSimplePlayerHandleKeydown;
if (typeof i == "function") return i(e), !!e?.defaultPrevented;
let a = (t?.closest?.(".mjr-mfv-player-host") || (r || t == null ? n?.querySelector?.(".mjr-mfv-player-host") : null))?._mjrMediaControlsHandle;
if (a && typeof a == "object") {
let t = String(e?.key || ""), n = () => {
e.preventDefault?.(), e.stopPropagation?.(), e.stopImmediatePropagation?.();
};
if ((t === " " || t === "Spacebar") && typeof a.togglePlay == "function") return a.togglePlay(), n(), !0;
if (t === "ArrowLeft" && typeof a.stepFrames == "function") return a.stepFrames(-1), n(), !0;
if (t === "ArrowRight" && typeof a.stepFrames == "function") return a.stepFrames(1), n(), !0;
}
return !1;
} catch (e) {
return console.debug?.(e), !1;
}
}
var Q = (e) => {
if (!S?.isVisible || i() || r().scope === "viewer") return;
let t = e?.key?.toLowerCase?.() || "", n = e?.target?.isContentEditable || e?.target?.closest?.("input, textarea, select, [contenteditable='true']");
if (n && Se(e) || n || Se(e)) return;
let a = () => {
e.preventDefault?.(), e.stopPropagation?.(), e.stopImmediatePropagation?.();
};
if (!e?.ctrlKey && !e?.metaKey && !e?.altKey && !e?.shiftKey) {
if (t === "v") {
a(), Y.toggle();
return;
}
if (t === "k") {
a(), Y.togglePreview();
return;
}
if (t === "l") {
a(), Y.toggleLive();
return;
}
if (t === "n") {
a(), Y.toggleNodeStream();
return;
}
t === "c" && (a(), Y.toggleCompareAB());
}
};
function $() {
X || typeof window < "u" && window?.addEventListener && (window.addEventListener(s.MFV_OPEN, pe), window.addEventListener(s.MFV_CLOSE, me), window.addEventListener(s.MFV_TOGGLE, he), window.addEventListener(s.MFV_LIVE_TOGGLE, Z), window.addEventListener(s.MFV_PREVIEW_TOGGLE, ge), window.addEventListener(s.MFV_NODESTREAM_TOGGLE, _e), window.addEventListener(s.MFV_POPOUT, ve), window.addEventListener(s.SETTINGS_CHANGED, be), window.addEventListener("keydown", Q, !0), window.addEventListener("beforeunload", ye), X = !0);
}
function Ce() {
typeof window > "u" || !window?.removeEventListener || (window.removeEventListener(s.MFV_OPEN, pe), window.removeEventListener(s.MFV_CLOSE, me), window.removeEventListener(s.MFV_TOGGLE, he), window.removeEventListener(s.MFV_LIVE_TOGGLE, Z), window.removeEventListener(s.MFV_PREVIEW_TOGGLE, ge), window.removeEventListener(s.MFV_NODESTREAM_TOGGLE, _e), window.removeEventListener(s.MFV_POPOUT, ve), window.removeEventListener(s.SETTINGS_CHANGED, be), window.removeEventListener("keydown", Q, !0), window.removeEventListener("beforeunload", ye)), X = !1;
}
function we({ reinstallGlobalHandlers: e = !1 } = {}) {
let t = !!S?.isVisible;
try {
S?.isPopped && S.popIn();
} catch (e) {
console.debug?.(e);
}
Ce(), H(), fe(), N(), j += 1, T = C(), E = w(), D = !1;
try {
b && b(!1);
} catch (e) {
console.debug?.(e);
}
se(), t && F(!1), e && $();
}
export { h as a, p as c, we as i, m as l, te as n, g as o, $ as r, _ as s, Y as t, f as u };
-106
View File
@@ -1,106 +0,0 @@
// ui/features/viewer/frameExport.ts
function e({ state: e, VIEWER_MODES: t, singleView: n, abView: r, sideView: i }) {
function a() {
try {
if (e?.mode === t.SINGLE) {
let e = n?.querySelector?.("canvas.mjr-viewer-media");
return e instanceof HTMLCanvasElement ? e : null;
}
if (e?.mode === t.AB_COMPARE) {
let t = String(e?.abCompareMode || "wipe");
if (t === "wipe" || t === "wipeV") {
let e = r?.querySelector?.("canvas.mjr-viewer-media[data-mjr-compare-role=\"A\"]"), n = r?.querySelector?.("canvas.mjr-viewer-media[data-mjr-compare-role=\"B\"]");
if (e instanceof HTMLCanvasElement && n instanceof HTMLCanvasElement) return {
a: e,
b: n,
mode: t
};
}
let n = r?.querySelector?.("canvas.mjr-viewer-media[data-mjr-compare-role=\"D\"]");
if (n instanceof HTMLCanvasElement) return n;
let i = r?.querySelector?.("canvas.mjr-viewer-media");
return i instanceof HTMLCanvasElement ? i : null;
}
if (e?.mode === t.SIDE_BY_SIDE) {
let e = i?.querySelector?.("canvas.mjr-viewer-media");
return e instanceof HTMLCanvasElement ? e : null;
}
return null;
} catch {
return null;
}
}
let o = (e, t = "image/png", n = .92) => new Promise((r) => {
try {
if (e?.toBlob) {
e.toBlob((e) => r(e), t, n);
return;
}
} catch (e) {
console.debug?.(e);
}
try {
let i = e?.toDataURL?.(t, n);
if (!i || typeof i != "string") return r(null);
let a = i.split(",")[1] || "", o = atob(a), s = new Uint8Array(o.length);
for (let e = 0; e < o.length; e++) s[e] = o.charCodeAt(e);
r(new Blob([s], { type: t }));
} catch {
r(null);
}
});
async function s({ toClipboard: t = !1 } = {}) {
try {
let n = a();
if (!n) return !1;
await new Promise((e) => requestAnimationFrame(e));
let r = null;
if (n instanceof HTMLCanvasElement) r = n;
else if (n?.a && n?.b) {
let t = n.a, i = n.b, a = Math.max(1, Math.min(Number(t.width) || 0, Number(i.width) || 0)), o = Math.max(1, Math.min(Number(t.height) || 0, Number(i.height) || 0));
if (!(a > 1 && o > 1)) return !1;
let s = document.createElement("canvas");
s.width = a, s.height = o;
let c = s.getContext("2d");
if (!c) return !1;
try {
c.drawImage(i, 0, 0, a, o);
} catch (e) {
console.debug?.(e);
}
let l = Math.max(0, Math.min(100, Number(e?._abWipePercent) || 50)) / 100;
try {
c.save(), c.beginPath(), n.mode === "wipeV" ? c.rect(0, 0, a, o * l) : c.rect(0, 0, a * l, o), c.clip(), c.drawImage(t, 0, 0, a, o), c.restore();
} catch (e) {
console.debug?.(e);
}
r = s;
}
if (!r) return !1;
let i = await o(r, "image/png");
if (!i) return !1;
if (t) try {
let e = globalThis?.ClipboardItem, t = navigator?.clipboard;
return !e || !t?.write ? !1 : (await t.write([new e({ "image/png": i })]), !0);
} catch {
return !1;
}
try {
let t = e?.assets?.[e?.currentIndex] || null, n = `${String(t?.filename || "frame").replace(/[\\/:*?"<>|]+/g, "_").replace(/\.[^.]+$/, "") || "frame"}_export.png`, r = URL.createObjectURL(i), a = document.createElement("a");
a.href = r, a.download = n, a.rel = "noopener", a.click();
try {
setTimeout(() => URL.revokeObjectURL(r), 2e3);
} catch (e) {
console.debug?.(e);
}
return !0;
} catch {
return !1;
}
} catch {
return !1;
}
}
return { exportCurrentFrame: s };
}
export { e as createFrameExporter };
-566
View File
@@ -1,566 +0,0 @@
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
import { m as t, o as n } from "./events-C65wdOHm.js";
import { h as r } from "./Viewer-W-G40j3K.js";
import { i, n as a, r as o, t as s, u as c } from "./SidebarWorkflowSection-DrCNoS04.js";
import { D as l, E as u, N as d, O as f, T as p, V as m, dt as h, ft as g, k as _, lt as v } from "./mjr-primevue-BRqtdXzb.js";
// ui/vue/components/viewer/ViewerMetadataBlock.vue?vue&type=script&setup=true&lang.ts
var y = { style: {
display: "flex",
"flex-direction": "column",
gap: "10px",
"margin-bottom": "14px"
} }, b = {
key: 0,
style: {
"font-size": "12px",
"font-weight": "600",
"letter-spacing": "0.02em",
color: "rgba(255,255,255,0.86)"
}
}, x = {
key: 1,
style: {
padding: "10px 12px",
"border-radius": "10px",
border: "1px solid rgba(33,150,243,0.35)",
background: "rgba(33,150,243,0.08)",
color: "rgba(255,255,255,0.86)",
"white-space": "pre-wrap"
}
}, S = { style: {
"font-size": "12px",
"font-weight": "700",
"margin-bottom": "6px"
} }, C = { style: {
"font-size": "12px",
opacity: "0.88"
} }, w = { style: {
"font-size": "12px",
"font-weight": "700",
"margin-bottom": "6px"
} }, T = { style: {
"font-size": "12px",
opacity: "0.9"
} }, E = {
key: 6,
style: {
padding: "10px 12px",
"border-radius": "10px",
border: "1px solid rgba(255,255,255,0.12)",
background: "rgba(255,255,255,0.06)",
color: "rgba(255,255,255,0.72)"
}
}, D = {
key: 7,
style: {
border: "1px solid rgba(255,255,255,0.10)",
"border-radius": "10px",
background: "rgba(255,255,255,0.04)",
overflow: "hidden"
}
}, O = { style: {
cursor: "pointer",
padding: "10px 12px",
color: "rgba(255,255,255,0.78)",
"user-select": "none"
} }, k = { style: {
margin: "0",
padding: "10px 12px",
"max-height": "280px",
overflow: "auto",
"font-size": "11px",
"line-height": "1.35",
color: "rgba(255,255,255,0.86)"
} }, A = /* @__PURE__ */ d({
__name: "ViewerMetadataBlock",
props: {
title: {},
asset: {},
loading: { type: Boolean },
onRetry: { type: [Function, null] }
},
setup(e) {
let r = e;
function c(e) {
try {
if (!e || typeof e != "object") return null;
let t = e?.metadata_raw;
return t && typeof t == "object" && t.geninfo_status && typeof t.geninfo_status == "object" ? t.geninfo_status : e?.geninfo_status && typeof e.geninfo_status == "object" ? e.geninfo_status : null;
} catch {
return null;
}
}
function d(e) {
let t = e?.metadata_raw ?? null;
if (!t) return null;
if (typeof t == "object") return t;
if (typeof t != "string") return null;
let n = t.trim();
if (!n) return null;
try {
let e = JSON.parse(n);
return e && typeof e == "object" ? e : null;
} catch {
return null;
}
}
function A(e) {
try {
let t = Object.entries(e || {});
if (!t.length) return !1;
let n = 0;
for (let [, e] of t.slice(0, 50)) if (e && typeof e == "object" && (e.inputs && typeof e.inputs == "object" && (n += 1), n >= 2)) return !0;
} catch {
return !1;
}
return !1;
}
function j(e) {
let t = d(e), n = e?.workflow || e?.Workflow || e?.comfy_workflow || t?.workflow || t?.Workflow || t?.comfy_workflow || null;
if (!n) return null;
if (typeof n == "object") return n;
if (typeof n != "string") return null;
let r = n.trim();
if (!r) return null;
try {
return JSON.parse(r);
} catch {
return null;
}
}
function M(e) {
let t = d(e), n = e?.prompt || e?.Prompt || t?.prompt || t?.Prompt || null;
if (!n) return null;
if (typeof n == "object") return A(n) ? n : null;
if (typeof n != "string") return null;
let r = n.trim();
if (!r) return null;
try {
let e = JSON.parse(r);
return A(e) ? e : null;
} catch {
return null;
}
}
function N(e) {
return !!(j(e) || M(e));
}
function P(e) {
let t = e || {}, n = t.generation_time || t.file_creation_time || t.mtime || t.created_at;
return !!(t.width && t.height || t.duration && t.duration > 0 || n || t.size && t.size > 0 || t.size_bytes && t.size_bytes >= 0 || t.metadata_raw?.raw_ffprobe || t.id != null || t.job_id || t.file_info?.job_id || t.source_node_id || t.file_info?.source_node_id || t.source_node_type || t.file_info?.source_node_type || t.workflow_id || t.file_info?.workflow_id || t.user_metadata?.workflow?.id || t.metadata?.workflow_id);
}
function F(e) {
if (e == null) return "";
let t = typeof e == "string" ? e : JSON.stringify(e, null, 2);
return t ? t.length > 4e4 ? `${t.slice(0, 4e4)}\n...(truncated)` : t : "";
}
function I() {
typeof r.onRetry == "function" && r.onRetry();
}
let L = p(() => c(r.asset)), R = p(() => o(r.asset)), z = p(() => R.value.kind !== "empty"), B = p(() => P(r.asset)), V = p(() => n.WORKFLOW_MINIMAP_ENABLED !== !1 && N(r.asset)), H = p(() => L.value && typeof L.value == "object" && L.value.kind === "fetch_error"), U = p(() => F(r.asset?.metadata_raw)), W = p(() => !r.loading && !H.value && !B.value && !z.value && !V.value), G = p(() => {
if (!H.value) return "";
let e = String(L.value?.message || L.value?.error || "Failed to load generation data."), n = String(L.value?.code || L.value?.stage || "").trim();
return n ? t("viewer.metadataErrorWithCode", "{message}\n\nCode: {code}\nClick to retry.", {
message: e,
code: n
}) : t("viewer.metadataErrorRetry", "{message}\n\nClick to retry.", { message: e });
});
return (e, n) => (m(), _("div", y, [
r.title ? (m(), _("div", b, g(r.title), 1)) : f("", !0),
r.loading ? (m(), _("div", x, [u("div", S, g(v(t)("status.loading", "Loading")), 1), u("div", C, g(v(t)("viewer.loadingGenerationData", "Loading generation data...")), 1)])) : f("", !0),
H.value ? (m(), _("div", {
key: 2,
style: h([{
padding: "10px 12px",
"border-radius": "10px",
border: "1px solid rgba(244,67,54,0.35)",
background: "rgba(244,67,54,0.08)",
color: "rgba(255,255,255,0.9)",
"white-space": "pre-wrap"
}, { cursor: r.onRetry ? "pointer" : "default" }]),
onClick: I
}, [u("div", w, g(v(t)("viewer.errorLoadingMetadata", "Error Loading Metadata")), 1), u("div", T, g(G.value), 1)], 4)) : f("", !0),
B.value ? (m(), l(i, {
key: 3,
asset: r.asset
}, null, 8, ["asset"])) : f("", !0),
z.value ? (m(), l(a, {
key: 4,
asset: r.asset
}, null, 8, ["asset"])) : f("", !0),
V.value ? (m(), l(s, {
key: 5,
asset: r.asset
}, null, 8, ["asset"])) : f("", !0),
W.value ? (m(), _("div", E, g(v(t)("viewer.noGenerationDataFile", "No generation data found for this file.")), 1)) : f("", !0),
U.value ? (m(), _("details", D, [u("summary", O, g(v(t)("msg.rawMetadata", "Raw metadata")), 1), u("pre", k, g(U.value), 1)])) : f("", !0)
]));
}
}), j = /* @__PURE__ */ e({
buildViewerMetadataBlocks: () => ee,
ensureViewerMetadataAsset: () => $
}), M = (e, t = null) => {
let n = r(e);
return n === void 0 ? t : n;
}, N = n?.VIEWER_GENINFO_TTL_MS ?? 3e4, P = n?.VIEWER_GENINFO_ERROR_TTL_MS ?? 8e3, F = n?.VIEWER_GENINFO_MAX_ENTRIES ?? 300, I = /* @__PURE__ */ new Map(), L = /* @__PURE__ */ new Map(), R = /* @__PURE__ */ new Map(), z = (e, t, n) => {
try {
let r = Date.now();
for (let [n, i] of e.entries()) i ? r - (i.at || 0) > t && e.delete(n) : e.delete(n);
if (e.size <= n) return;
let i = Array.from(e.entries()).sort((e, t) => (e?.[1]?.at || 0) - (t?.[1]?.at || 0)), a = e.size - n;
for (let t = 0; t < a; t++) {
let n = i[t]?.[0];
n != null && e.delete(n);
}
} catch (e) {
console.debug?.(e);
}
}, B = (e, t, n) => {
try {
let r = e.get(t);
return r ? Date.now() - (r.at || 0) > n ? (e.delete(t), null) : r.data ?? null : null;
} catch {
return null;
}
}, V = (e, t, n, r, i) => {
try {
e.set(t, {
at: Date.now(),
data: n
}), z(e, r, i);
} catch (e) {
console.debug?.(e);
}
}, H = (e) => {
try {
let t = e?.id;
if (t != null) return `id:${t}`;
let n = J(e);
if (n) return `fp:${n}`;
let r = String(e?.filename || e?.name || "").trim(), i = String(e?.subfolder || "").trim(), a = String(e?.source || e?.type || "output").trim().toLowerCase();
if (r) return `name:${a}:${i}:${r}`;
} catch (e) {
console.debug?.(e);
}
return null;
}, U = (e) => {
try {
if (!e || typeof e != "object") return null;
let t = e?.metadata_raw;
return t && typeof t == "object" && t.geninfo_status && typeof t.geninfo_status == "object" ? t.geninfo_status : e?.geninfo_status && typeof e.geninfo_status == "object" ? e.geninfo_status : null;
} catch {
return null;
}
}, W = (e, t) => {
try {
if (!e || typeof e != "object" || !t || typeof t != "object") return;
try {
e.geninfo_status = t;
} catch (e) {
console.debug?.(e);
}
try {
let n = e.metadata_raw;
if (n && typeof n == "object") {
n.geninfo_status = t, e.metadata_raw = n;
return;
}
if (typeof n == "string") return;
} catch (e) {
console.debug?.(e);
}
try {
e.metadata_raw = { geninfo_status: t };
} catch (e) {
console.debug?.(e);
}
} catch (e) {
console.debug?.(e);
}
}, G = (e) => {
try {
if (!e || typeof e != "object") return !1;
if (e.geninfo && typeof e.geninfo == "object" && Object.keys(e.geninfo).length || e.prompt != null || e.workflow != null || e.metadata != null || e.exif != null) return !0;
let t = e.metadata_raw;
if (t && typeof t == "object") {
for (let e of [
"geninfo",
"GenInfo",
"generation",
"prompt",
"Prompt",
"negative_prompt",
"workflow",
"Workflow",
"comfy_workflow",
"geninfo_status"
]) if (t[e] != null) return !0;
return !1;
}
if (typeof t == "string") {
let e = t.trim();
if (!e || e === "{}" || e === "null" || e === "[]" || e === "{{}}") return !1;
for (let t of [
"Negative prompt:",
"\"prompt\"",
"\"negative_prompt\"",
"\"geninfo\"",
"\"workflow\"",
"\"comfy_workflow\""
]) if (e.includes(t)) return !0;
return !1;
}
return !1;
} catch {
return !1;
}
}, K = (e) => {
let t = typeof e == "string" ? e.trim() : "";
if (!t || t.includes("\n")) return !1;
if (/^[A-Za-z]:[\\/]/.test(t)) return !0;
let n = t.replace(/\\/g, "/");
return /(?:^|\/)[^/\n]+\.(?:png|jpe?g|webp|gif|bmp|tiff?|avif|heic|heif|apng|hdr|svg|mp4|webm|mov|mkv|avi|m4v|mp3|wav|flac|ogg)$/i.test(n);
}, q = (e) => {
try {
if (!e || typeof e != "object") return !1;
if (e.geninfo && typeof e.geninfo == "object" && Object.keys(e.geninfo).length) return !0;
if (e.metadata_raw && typeof e.metadata_raw == "object") {
let t = e.metadata_raw;
if (t.geninfo || t.GenInfo || t.generation || t.parameters || typeof t.prompt == "string" && t.prompt.trim() && !K(t.prompt) || t.prompt && typeof t.prompt == "object") return !0;
let n = t.raw_ffprobe?.format?.tags || t.ffprobe?.format?.tags || null;
if (n && typeof n == "object") {
let e = n.prompt || n["comfyui:prompt"] || n.comfy_prompt;
if (e && !K(e)) return !0;
}
}
return !!(e.prompt && typeof e.prompt == "object" || typeof e.prompt == "string" && e.prompt.trim() && !K(e.prompt));
} catch {
return !1;
}
}, J = (e) => {
let t = e?.filepath || e?.path || e?.file_info?.filepath || e?.file_info?.path || e?.filePath || null;
return typeof t == "string" && t.trim() || null;
}, Y = (e) => {
try {
if (String(e?.mime || e?.mimetype || e?.type || "").toLowerCase().startsWith("video/")) return !0;
let t = (J(e) || String(e?.filename || e?.name || "")).split(".").pop()?.toLowerCase?.() || "";
return [
"mp4",
"webm",
"mov",
"mkv",
"avi",
"m4v",
"gif"
].includes(t);
} catch {
return !1;
}
}, X = (e) => {
if (!e) return null;
if (typeof e == "object") return e;
if (typeof e != "string") return null;
let t = e.trim();
return t ? M(() => {
let e = JSON.parse(t);
return e && typeof e == "object" ? e : null;
}, null) : null;
}, Z = (e) => {
try {
if (!Y(e) || e?.geninfo || e?.prompt || e?.workflow || e?.metadata) return;
let t = X(e?.metadata_raw) || {};
if (t.geninfo_status) return;
if (e?.geninfo_status) {
t.geninfo_status = e.geninfo_status, e.metadata_raw = t;
return;
}
t.geninfo_status = { kind: "media_pipeline" }, e.metadata_raw = t;
} catch (e) {
console.debug?.(e);
}
}, Q = (e, t) => {
let n = t && typeof t == "object" ? t : null;
if (!n) return e;
try {
e.prompt = e.prompt ?? n.prompt;
} catch (e) {
console.debug?.(e);
}
try {
e.workflow = e.workflow ?? n.workflow;
} catch (e) {
console.debug?.(e);
}
try {
e.geninfo = e.geninfo ?? n.geninfo;
} catch (e) {
console.debug?.(e);
}
try {
e.geninfo_status = e.geninfo_status ?? n.geninfo_status;
} catch (e) {
console.debug?.(e);
}
try {
e.exif = e.exif ?? n.exif;
} catch (e) {
console.debug?.(e);
}
try {
e.ffprobe = e.ffprobe ?? n.ffprobe;
} catch (e) {
console.debug?.(e);
}
try {
if (e.metadata_raw == null) e.metadata_raw = n;
else {
let t = X(e.metadata_raw);
if (t && typeof t == "object") {
for (let e of [
"geninfo_status",
"workflow",
"prompt",
"geninfo"
]) (t[e] == null && n[e] != null || e === "prompt" && K(t[e]) && n[e] != null) && (t[e] = n[e]);
n.raw_ffprobe && t.raw_ffprobe == null && (t.raw_ffprobe = n.raw_ffprobe), n.ffprobe && t.ffprobe == null && (t.ffprobe = n.ffprobe), e.metadata_raw = t;
}
}
} catch (e) {
console.debug?.(e);
}
return e;
};
async function $(e, { getAssetMetadata: t, getFileMetadataScoped: n, metadataCache: r, signal: i } = {}) {
if (!e || typeof e != "object") return e;
let a = e?.id ?? null, o = H(e), s = e, c = o ? B(I, o, N) : null;
if (c && typeof c == "object") return {
...e,
...c
};
let l = o ? B(L, o, P) : null;
if (l) {
try {
W(s, l);
} catch (e) {
console.debug?.(e);
}
return s;
}
if (o && R.has(o)) try {
let e = R.get(o);
if (e && typeof e.then == "function") return await e;
} catch (e) {
console.debug?.(e);
}
let u = async () => {
let c = a == null ? null : M(() => r?.getCached?.(a)?.data || null, null);
c && typeof c == "object" && (s = {
...e,
...c
});
let l = !!(s?.has_generation_data || s?.has_workflow || s?.has_generation || s?.has_generation_info), u = !!(s?.geninfo || s?.prompt || s?.workflow || s?.metadata), d = null;
if (a != null && (!q(s) || l && !u)) {
let e = await M(() => t?.(a, i ? { signal: i } : {}), null);
e?.ok && e.data && typeof e.data == "object" ? (s = {
...s,
...e.data
}, M(() => r?.setCached?.(a, e.data))) : e && e?.code !== "ABORTED" && (d = {
kind: "fetch_error",
stage: "asset",
code: e?.code || "FETCH_ERROR",
message: e?.error || "Failed to load asset metadata"
});
}
if (!q(s)) try {
let e = String(s?.source || s?.type || "output").trim().toLowerCase() || "output", t = String(s?.filename || s?.name || s?.file_info?.filename || "").trim(), r = String(s?.subfolder || s?.file_info?.subfolder || "").trim(), a = String(s?.root_id || s?.rootId || s?.file_info?.root_id || "").trim(), o = String(s?.filepath || s?.path || s?.file_info?.filepath || "").trim();
if (t) {
let c = await M(() => n?.({
type: e,
filename: t,
subfolder: r,
root_id: a,
filepath: o
}, i ? { signal: i } : {}), null);
c?.ok && c.data ? s = Q({ ...s }, c.data) : c && c?.code !== "ABORTED" && (d = {
kind: "fetch_error",
stage: "file_scoped",
code: c?.code || "FETCH_ERROR",
message: c?.error || "Failed to extract file metadata"
});
}
} catch (e) {
console.debug?.(e);
}
if (Z(s), !G(s) && d) {
let e = U(s);
e && e.kind === "media_pipeline" || W(s, d);
}
return q(s) && o ? V(I, o, s, N, F) : d && o && V(L, o, d, P, F), s;
};
if (o) {
let e = () => {
try {
R.delete(o);
} catch (e) {
console.debug?.(e);
}
};
try {
i?.addEventListener?.("abort", e, { once: !0 });
} catch (e) {
console.debug?.(e);
}
let t = u().finally(() => {
try {
i?.removeEventListener?.("abort", e);
} catch (e) {
console.debug?.(e);
}
try {
R.delete(o);
} catch (e) {
console.debug?.(e);
}
});
return R.set(o, t), await t;
}
return await u();
}
function ee({ title: e, asset: t, ui: n } = {}) {
let r = document.createElement("div");
try {
let { app: i } = c(A, {
title: e,
asset: t,
loading: !!n?.loading,
onRetry: typeof n?.onRetry == "function" ? n.onRetry : null
});
return i.mount(r), r._mjrDispose = () => {
try {
i.unmount();
} catch (e) {
console.debug?.(e);
}
}, r;
} catch (e) {
console.debug?.(e);
}
let i = document.createElement("div");
if (i.style.cssText = "display:flex; flex-direction:column; gap:10px; margin-bottom: 14px;", e) {
let t = document.createElement("div");
t.textContent = e, t.style.cssText = "font-size: 12px; font-weight: 600; letter-spacing: 0.02em; color: rgba(255,255,255,0.86);", i.appendChild(t);
}
let a = t?.metadata_raw;
if (a != null) {
let e = document.createElement("details");
e.style.cssText = "border: 1px solid rgba(255,255,255,0.10); border-radius: 10px; background: rgba(255,255,255,0.04); overflow: hidden;";
let t = document.createElement("summary");
t.textContent = "Raw metadata", t.style.cssText = "cursor: pointer; padding: 10px 12px; color: rgba(255,255,255,0.78); user-select: none;";
let n = document.createElement("pre");
n.style.cssText = "margin:0; padding: 10px 12px; max-height: 280px; overflow:auto; font-size: 11px; line-height: 1.35; color: rgba(255,255,255,0.86);";
let r = typeof a == "string" ? a : JSON.stringify(a, null, 2);
r.length > 4e4 && (r = `${r.slice(0, 4e4)}\n...(truncated)`), n.textContent = r, e.appendChild(t), e.appendChild(n), i.appendChild(e);
}
return i;
}
export { j as n, $ as t };
+2490
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-792
View File
@@ -1,792 +0,0 @@
// ui/components/sidebar/parsers/a1111ParamsParser.ts
function e(e) {
if (!e || typeof e != "string") return null;
let t = {}, n = e.split(/\nNegative prompt:\s*/i);
if (n[0] && (t.prompt = n[0].trim()), n[1]) {
let e = n[1].match(/^(.*?)\n?(Steps:.*)$/s);
if (e) {
t.negative_prompt = e[1].trim();
let n = e[2], r = (e) => {
let t = n.match(e);
return t ? t[1].trim() : null;
};
t.steps = r(/Steps:\s*(\d+)/i), t.sampler = r(/Sampler:\s*([^,\n]+)/i), t.cfg = r(/CFG scale:\s*([\d.]+)/i), t.seed = r(/Seed:\s*(\d+)/i), t.width = r(/Size:\s*(\d+)x\d+/i), t.height = r(/Size:\s*\d+x(\d+)/i), t.model = r(/Model:\s*([^,\n]+)/i), t.denoising = r(/Denoising strength:\s*([\d.]+)/i), t.clip_skip = r(/Clip skip:\s*(\d+)/i);
} else t.negative_prompt = n[1].trim();
}
return Object.keys(t).length > 0 ? t : null;
}
// ui/components/sidebar/parsers/comfyWorkflowParser.ts
function t(e) {
try {
let t = Object.entries(e || {});
if (!t.length) return !1;
let n = 0;
for (let [, e] of t.slice(0, 50)) if (e && typeof e == "object" && (e.inputs && typeof e.inputs == "object" && (n += 1), n >= 2)) return !0;
return !1;
} catch {
return !1;
}
}
function n(e) {
if (!e || typeof e != "object") return null;
let t = {}, n = [], i = v(e), o = [], s = O(e);
for (let e of s) {
if (!e || typeof e != "object") continue;
let r = M(e);
if (!r || typeof r != "object") continue;
let a = String(e?.class_type || e?.type || "").toLowerCase(), s = String(e?.title || e?._meta?.title || "").toLowerCase(), c = (e, n) => {
if (t[e] || typeof n != "string") return;
let r = n.trim();
r && (/^[\d\s.,+-]+$/.test(r) || (t[e] = r));
};
if ((a.includes("cliptextencode") || a.includes("clip_text_encode") || s.includes("clip text encode")) && typeof r.text == "string") {
let e = s.includes("negative");
s.includes("positive") || s.includes("(prompt)") || s.includes("prompt"), c(e ? "negative_prompt" : "prompt", r.text);
}
if (c("negative_prompt", r.negative_prompt), c("negative_prompt", r.negative), !t.prompt && typeof r.text == "string") {
let e = r.text.trim();
(a.includes("prompt") || a.includes("encode") || a.includes("positive") || a.includes("negative") || s.includes("prompt") || s.includes("positive") || s.includes("negative")) && e.length >= 12 && /[a-zA-Z]/.test(e) && c("prompt", e);
}
let l = T(r.seed, i), u = T(r.steps, i), d = T(r.cfg, i), f = T(r.denoise, i);
if (l !== void 0 && t.seed === void 0 && (t.seed = l), u !== void 0 && t.steps === void 0 && (t.steps = u), d !== void 0 && t.cfg === void 0 && (t.cfg = d), r.sampler_name && !t.sampler && (t.sampler = r.sampler_name), r.scheduler && !t.scheduler && (t.scheduler = r.scheduler), f !== void 0 && t.denoise === void 0 && (t.denoise = f), y(a)) {
let t = b(e, i), n = w(e, i), a = E(e?.inputs?.model, i) || E(n.model_link, i);
o.push({
sampler_name: r.sampler_name || n.sampler_name || r.sampler,
scheduler: r.scheduler || n.scheduler,
steps: T(r.steps, i) ?? n.steps,
cfg: T(r.cfg, i) ?? n.cfg,
denoise: T(r.denoise, i) ?? n.denoise,
seed: T(r.seed ?? r.noise_seed, i) ?? n.seed,
start_at_step: T(r.start_at_step, i),
end_at_step: T(r.end_at_step, i),
model: a,
pass_stage: t
});
}
r.width !== void 0 && t.width === void 0 && (t.width = r.width), r.height !== void 0 && t.height === void 0 && (t.height = r.height);
let p = (e, n) => {
if (t[e] || typeof n != "string") return;
let r = n.trim();
r && (t[e] = r);
}, m = [
r.ckpt_name,
r.checkpoint,
r.checkpoint_name,
r.model_name,
r.model
];
for (let e of m) !t.model && !a.includes("upscale") && p("model", e);
a.includes("upscale") && p("upscaler", r.model_name || r.upscale_model || r.model), p("vae", r.vae_name || r.vae), p("clip", r.clip_name || r.clip);
let h = r.unet_name || r.unet;
if (typeof h == "string" && h.trim()) {
let e = t.models && typeof t.models == "object" ? t.models : {};
/high[_\s-]?noise/i.test(h) ? e.unet_high_noise = { name: h.trim() } : /low[_\s-]?noise/i.test(h) ? e.unet_low_noise = { name: h.trim() } : p("unet", h), Object.keys(e).length && (t.models = e);
}
if (p("diffusion", r.diffusion_name || r.diffusion_model || r.diffusion), a.includes("lora") || a.includes("loraloader")) {
let e = r.lora_name || r.lora || r.name || null, t = r.strength_model ?? r.strength ?? r.weight ?? r.lora_strength ?? null;
if (e) {
let i = { name: e };
r.strength_model === void 0 ? i.weight = t : i.strength_model = r.strength_model, n.push(i);
}
}
}
let c = _(n), l = _(o, g);
if (c.length && (t.loras = c), l.length > 1 && (t.all_samplers = l), t.seed === void 0) {
let e = l.find((e) => e?.seed !== void 0 && e?.seed !== null)?.seed;
e != null && (t.seed = e);
}
return a(t, s, i), r(t, s), Object.keys(t).length > 0 ? t : null;
}
function r(e, t) {
if (e.ideogram) return;
let n = t.find((e) => {
let t = String(e?.class_type || e?.type || e?.comfyClass || "").toLowerCase();
return t.includes("ideogram4promptbuilderkj") || t.includes("ideogram4promptbuilder");
});
if (!n) return;
let r = M(n) || {}, a = i(o(r.elements_data, r.elements, r.bboxes)), s = i(o(r.style_palette_data, r.palette, r.color_palette)), c = m({
width: o(r.width, r.custom_width),
height: o(r.height, r.custom_height),
high_level_description: r.high_level_description,
background: r.background,
style: r.style,
photo_style: r["style.photo"] || r.photo_style,
aesthetics: r.aesthetics,
lighting: r.lighting,
medium: r.medium,
bg_brightness: r.bg_brightness,
coord_mode: r.coord_mode,
bbox_order: r.bbox_order,
color_palette: s,
elements: a
});
if (!Object.keys(c).length) return;
let l = JSON.stringify(c, null, 2);
e.ideogram = m({
title: String(n?._meta?.title || n?.title || "Ideogram 4").trim(),
json: l,
payload: c,
high_level_description: c.high_level_description,
background: c.background,
elements: Array.isArray(a) ? a : [],
color_palette: Array.isArray(s) ? s : []
}), e.prompt = l, e.workflow_type = e.workflow_type || "ideogram";
}
function i(e) {
if (e && typeof e == "object" || typeof e != "string" || !e.trim()) return e;
try {
return JSON.parse(e);
} catch {
return e;
}
}
function a(e, t, n) {
if (e.ltx_director) return;
let r = t.find((e) => {
let t = String(e?.class_type || e?.type || e?.comfyClass || "").toLowerCase();
return t === "ltxdirector" || t === "ltxdirectorguide";
});
if (!r) return;
let i = M(r) || {}, a = r?.properties && typeof r.properties == "object" ? r.properties : {}, l = s(o(i.timeline_data, a.timeline_data)), u = o(i.frame_rate, a.frame_rate, l.frame_rate, 0), d = Number(u), f = Number.isFinite(d) && d > 0 ? d : null, h = c(l.segments, f), g = String(o(l.global_prompt, i.global_prompt, a.global_prompt, "") || "").trim(), _ = p(t, r, n), v = o(i.custom_width, a.custom_width, i.width), y = o(i.custom_height, a.custom_height, i.height);
e.ltx_director = m({
title: String(r?._meta?.title || r?.title || "LTX Director").trim(),
global_prompt: g,
frame_rate: u,
duration_frames: o(i.duration_frames, a.duration_frames),
duration_seconds: o(i.duration_seconds, a.duration_seconds),
width: v,
height: y,
segments: h,
models: _
});
let b = [g, ...h.map((e) => e.prompt)].filter((e) => String(e || "").trim());
!e.prompt && b.length && (e.prompt = b.join("\n")), !e.models && Object.keys(_).length && (e.models = _), !e.width && v && (e.width = v), !e.height && y && (e.height = y);
}
function o(...e) {
return e.find((e) => e != null && e !== "");
}
function s(e) {
if (e && typeof e == "object") return e;
if (typeof e != "string" || !e.trim()) return {};
try {
let t = JSON.parse(e);
return t && typeof t == "object" ? t : {};
} catch {
return {};
}
}
function c(e, t) {
return Array.isArray(e) ? e.map((e, n) => l(e, n, t)).filter((e) => e.prompt || e.filename || e.in !== "" || e.out !== "") : [];
}
function l(e, t, n) {
let r = o(e?.start, e?.startFrame, e?.in, e?.from, e?.start_frame), i = o(e?.end, e?.endFrame, e?.out, e?.to, e?.end_frame), a = o(e?.length, e?.duration, e?.frames, e?.duration_frames), s = i === void 0 ? u(r, a) : i, c = String(o(e?.prompt, e?.text, e?.caption, "") || "").trim(), l = String(o(e?.imageFile, e?.videoFile, e?.audioFile, e?.filename, e?.file, e?.path, "") || "").trim();
return m({
index: t + 1,
id: e?.id,
prompt: c,
in: d(r, n),
out: d(s, n),
in_frame: r,
out_frame: s,
filename: l,
type: e?.type
});
}
function u(e, t) {
let n = Number(e), r = Number(t);
if (Number.isFinite(n) && Number.isFinite(r)) return n + r;
}
function d(e, t) {
if (e == null || e === "") return "";
let n = Number(e);
return !Number.isFinite(n) || !t ? String(e) : f(n / t);
}
function f(e) {
if (!Number.isFinite(e)) return "";
let t = Math.max(0, e);
if (t < 60) return `${t < 10 && Math.abs(t - Math.round(t)) > .05 ? Math.round(t * 10) / 10 : Math.round(t)}s`;
let n = Math.floor(t / 60), r = Math.round(t % 60);
return r === 0 ? `${n}m` : `${n}m ${r}s`;
}
function p(e, t, n) {
let r = {}, i = (e, t) => {
if (r[e] || typeof t != "string") return;
let n = t.trim();
n && (r[e] = n);
}, a = M(t) || {};
i("unet", E(a.model, n)), i("clip", E(a.clip, n)), i("audio_vae", E(a.audio_vae, n)), i("video_vae", E(a.video_vae, n));
for (let t of e) {
let e = M(t) || {}, n = String(t?.class_type || t?.type || "").toLowerCase();
if (n.includes("unet") && i("unet", e.unet_name || e.model_name), n.includes("latentupscale") && i("upscaler", e.model_name || e.upscale_model), n.includes("clip") && i("clip", e.clip_name || e.clip_name1), n.includes("vae")) {
let t = e.vae_name || e.model_name;
/audio/i.test(String(t || "")) || n.includes("audio") ? i("audio_vae", t) : /tae|tiny/i.test(String(t || "")) ? i("tiny_vae", t) : i("video_vae", t);
}
}
return r;
}
function m(e) {
let t = {};
for (let [n, r] of Object.entries(e || {})) r != null && r !== "" && (Array.isArray(r) && r.length === 0 || (!r || typeof r != "object" || Array.isArray(r) || Object.keys(r).length !== 0) && (t[n] = r));
return t;
}
function h(e) {
if (!e || typeof e != "object") return JSON.stringify(e);
let t = {};
for (let n of Object.keys(e).sort()) {
let r = e[n];
r != null && r !== "" && (t[n] = r);
}
return JSON.stringify(t);
}
function g(e) {
return !e || typeof e != "object" ? h(e) : [
e.sampler_name || e.sampler || "",
e.scheduler || "",
e.steps ?? "",
e.cfg ?? "",
e.denoise ?? "",
e.seed ?? "",
e.start_at_step ?? "",
e.end_at_step ?? ""
].map((e) => String(e)).join("::");
}
function _(e, t = h) {
let n = /* @__PURE__ */ new Set(), r = [];
for (let i of e) {
let e = t(i);
n.has(e) || (n.add(e), r.push(i));
}
return r;
}
function v(e) {
if (!e || typeof e != "object") return null;
let n = e.prompt;
if (typeof n == "string" && n.trim()) try {
n = JSON.parse(n);
} catch {
n = null;
}
let r = n && typeof n == "object" ? n : e;
return t(r) ? r : null;
}
function y(e) {
return e.includes("ksamplerselect") || e.includes("samplerselect") ? !1 : e.includes("ksampler") || e.includes("samplercustom") || e.includes("sampler_custom");
}
function b(e, t) {
if (!t) return "";
let n = E(e?.inputs?.model, t).toLowerCase();
if (x(n)) return "high";
if (S(n)) return "low";
if (/\b(upscale|upscaler|_to_\d{3,5}|to[_-]?\d{3,5})\b/i.test(n)) return "upscale";
let r = C(e, t).join(" ");
if (x(r)) return "high";
if (S(r)) return "low";
if (/\b(pidconditioning|imageupscalewithmodel|ultimate|supir|esrgan|realesrgan)\b/i.test(r)) return "upscale";
let i = C(e, t, "latent_image").join(" ");
return /\b(inpaint|vaeencodeforinpaint|setlatentnoise mask|masktoimage)\b/i.test(i) ? "inpaint" : /\b(latentupscale|latentupsampler|imageupscalewithmodel|ultimate|supir|esrgan|realesrgan)\b/i.test(i) && !x(r) && !S(r) ? "upscale" : /\b(vaeencode|loadimage|imagebatch|imagetolatent)\b/i.test(i) ? "img2img" : /\b(emptylatentimage|emptysd3latentimage|emptychromaradiancelatentimage|empty latent)\b/i.test(i) ? "txt2img" : "";
}
function x(e) {
return /(?:^|[^a-z0-9])high[_\s-]?noise(?:[^a-z0-9]|$)/i.test(String(e || ""));
}
function S(e) {
return /(?:^|[^a-z0-9])low[_\s-]?noise(?:[^a-z0-9]|$)/i.test(String(e || ""));
}
function C(e, t, n) {
let r = n ? [e?.inputs?.[n]] : Object.values(e?.inputs || {}), i = [], a = /* @__PURE__ */ new Set(), o = (e) => {
let n = String(e?._mjrPromptId || e?.id || "");
if (n && a.has(n)) return;
n && a.add(n);
let r = String(e?.class_type || e?.type || e?._meta?.title || "");
r && i.push(r.toLowerCase());
let s = e?.inputs && typeof e.inputs == "object" ? e.inputs : {};
for (let e of Object.values(s)) {
let n = D(e, t);
n && o(n);
}
};
for (let e of r) {
let n = D(e, t);
n && o(n);
}
return i;
}
function w(e, t) {
let n = {};
if (!t) return n;
let r = D(e?.inputs?.noise, t)?.inputs || {};
r.noise_seed !== void 0 && (n.seed = r.noise_seed), r.seed !== void 0 && (n.seed = r.seed);
let i = D(e?.inputs?.guider, t)?.inputs || {};
i.cfg !== void 0 && (n.cfg = i.cfg), i.model !== void 0 && (n.model_link = i.model);
let a = D(e?.inputs?.sampler, t)?.inputs || {};
a.sampler_name && (n.sampler_name = a.sampler_name);
let o = D(e?.inputs?.sigmas, t)?.inputs || {};
if (o.scheduler && (n.scheduler = o.scheduler), o.steps !== void 0 && (n.steps = T(o.steps, t)), o.denoise !== void 0 && (n.denoise = T(o.denoise, t)), n.steps === void 0 && typeof o.sigmas == "string") {
let e = o.sigmas.split(",").map((e) => e.trim()).filter(Boolean).length;
e > 1 && (n.steps = Math.max(1, e - 1));
}
return n;
}
function T(e, t, n = /* @__PURE__ */ new Set()) {
if (e == null || !Array.isArray(e)) return e;
if (!t) return;
let r = D(e, t);
if (!r) return;
let i = String(r?._mjrPromptId || r?.id || e[0] || "");
if (i && n.has(i)) return;
i && n.add(i);
let a = r?.inputs && typeof r.inputs == "object" ? r.inputs : {};
if (String(r?.class_type || r?.type || "").toLowerCase().includes("switch")) return T(T(a.switch, t, n) ? a.on_true : a.on_false, t, n);
if (Object.prototype.hasOwnProperty.call(a, "value")) return a.value;
for (let e of [
"number",
"int",
"float",
"seed",
"steps",
"cfg"
]) if (Object.prototype.hasOwnProperty.call(a, e)) return a[e];
}
function E(e, t) {
if (!t) return "";
let n = /* @__PURE__ */ new Set(), r = (e) => {
let i = D(e, t);
if (!i) return "";
let a = String(i?._mjrPromptId || i?.id || "");
if (a && n.has(a)) return "";
a && n.add(a);
let o = i?.inputs || {};
if (String(i?.class_type || i?.type || "").toLowerCase().includes("switch")) {
let e = T(o.switch, t), n = r(e ? o.on_true : o.on_false);
if (n) return n;
}
let s = o.unet_name || o.ckpt_name || o.model_name || o.model || o.checkpoint || o.checkpoint_name || "";
if (typeof s == "string" && s.trim()) return s.trim();
for (let e of Object.values(o)) {
let t = r(e);
if (t) return t;
}
return "";
};
return r(e);
}
function D(e, t) {
if (!Array.isArray(e) || e.length < 1) return null;
let n = String(e[0]), r = t[n];
if (!r || typeof r != "object") return null;
if (!r._mjrPromptId) try {
Object.defineProperty(r, "_mjrPromptId", {
value: n,
enumerable: !1
});
} catch {}
return r;
}
function O(e, n = /* @__PURE__ */ new WeakSet()) {
if (!e || typeof e != "object" || n.has(e)) return [];
n.add(e);
let r = e.prompt;
if (typeof r == "string" && r.trim()) try {
r = JSON.parse(r);
} catch {
r = null;
}
let i = r && typeof r == "object" ? r : null;
if (i && t(i)) return Object.values(i);
let a = [];
t(e) && a.push(...Object.values(e));
for (let t of k(e)) {
let e = Array.isArray(t?.nodes) ? t.nodes.filter(Boolean) : [];
a.push(...e);
for (let t of e) for (let e of j(t)) a.push(...O(e, n));
for (let e of A(t)) a.push(...O(e, n));
}
return a;
}
function k(e) {
if (!e || typeof e != "object") return [];
let t = [], n = (e) => {
e && typeof e == "object" && Array.isArray(e.nodes) && t.push(e);
};
n(e);
for (let t of [
"workflow",
"Workflow",
"template",
"Template",
"subgraph",
"Subgraph",
"graph",
"lgraph"
]) n(e?.[t]);
return t;
}
function A(e) {
return [
...Array.isArray(e?.definitions?.subgraphs) ? e.definitions.subgraphs : [],
...Array.isArray(e?.subgraphs) ? e.subgraphs : [],
...Array.isArray(e?.rootGraph?.subgraphs) ? e.rootGraph.subgraphs : []
].filter((e) => e && typeof e == "object" && Array.isArray(e.nodes));
}
function j(e) {
return [
e?.subgraph,
e?._subgraph,
e?.subgraph?.graph,
e?.subgraph?.lgraph,
e?.properties?.subgraph,
e?.subgraph_instance,
e?.subgraph_instance?.graph,
e?.inner_graph,
e?.subgraph_graph
].filter((e) => e && typeof e == "object" && Array.isArray(e.nodes));
}
function M(e) {
let t = e?.inputs;
if (t && typeof t == "object" && !Array.isArray(t)) return t;
let n = e?.widgets_values, r = {};
if (n && typeof n == "object" && !Array.isArray(n)) {
for (let [e, t] of Object.entries(n)) r[e] = t;
return r;
}
if (!Array.isArray(t) || !Array.isArray(n)) return null;
let i = t.filter(N);
for (let e = 0; e < n.length; e += 1) {
let a = i[e] || t[e] || null, o = String(a?.label || a?.localized_name || a?.name || a?.widget?.name || a?.widget?.label || "").trim();
r[o || `param_${e + 1}`] = n[e];
}
return r;
}
function N(e) {
if (!e || typeof e != "object") return !1;
if (e.widget === !0 || e.widget && typeof e.widget == "object" || typeof e.widget == "string" && e.widget.trim()) return !0;
if (e.link != null) return !1;
let t = String(e.type || "").trim().toUpperCase();
return [
"INT",
"FLOAT",
"STRING",
"BOOLEAN",
"BOOL",
"COMBO",
"ENUM"
].includes(t);
}
// ui/components/sidebar/parsers/geninfoParser.ts
var P = /^(?:[a-z]:[\\/]|[\\/]{1,2}|\.{1,2}[\\/]|~[\\/]).+?[\\/][^\\/\n]+\.(?:png|jpe?g|webp|gif|bmp|tiff?|avif|heic|heif|apng|hdr|svg|mp4|webm|mov|mkv|avi|m4v|mp3|wav|flac|ogg|glb|gltf|obj|fbx|ply|stl|ckpt|safetensors|pt|pth|bin|gguf|json|ya?ml)$/i, F = /^(?!.*[,;])(?!.*\b(?:cinematic|portrait|landscape|lighting|style|detailed|masterpiece|photo|render)\b).*(?:[\\/][^\\/\n]+){2,}\.(?:png|jpe?g|webp|gif|bmp|tiff?|avif|heic|heif|apng|hdr|svg|mp4|webm|mov|mkv|avi|m4v|mp3|wav|flac|ogg|glb|gltf|obj|fbx|ply|stl|ckpt|safetensors|pt|pth|bin|gguf|json|ya?ml)$/i, I = /* @__PURE__ */ new Set([
"workflow",
"quicktime:workflow",
"keys:workflow",
"comfyui:workflow",
"comfy_workflow",
"comfyuiworkflow"
]), L = /* @__PURE__ */ new Set([
"prompt",
"quicktime:prompt",
"keys:prompt",
"comfyui:prompt",
"comfy_prompt",
"comfyuiprompt"
]);
function R(r) {
if (!r) return null;
if (typeof r == "object") {
let i = r.geninfo || r.GenInfo || r.generation || null;
if (i && typeof i == "object") {
let e = {}, t = /* @__PURE__ */ new Set(), n = (e, n) => {
if (n && typeof n == "object" && (n.confidence === "override" || n.source === "majoor_geninfo")) for (let n of Array.isArray(e) ? e : [e]) t.add(n);
}, a = i.positive?.value ?? i.positive?.text ?? null, o = i.negative?.value ?? i.negative?.text ?? null;
typeof a == "string" && a.trim() && (e.prompt = a), typeof o == "string" && o.trim() && (e.negative_prompt = o), n("prompt", i.positive), n("negative_prompt", i.negative);
let s = i.checkpoint?.name ?? i.checkpoint ?? null;
typeof s == "string" && s.trim() && (e.model = s), n(["model", "checkpoint"], i.checkpoint);
let c = i.clip?.name ?? i.clip ?? null;
typeof c == "string" && c.trim() && (e.clip = c), n("clip", i.clip);
let l = i.vae?.name ?? i.vae ?? null;
typeof l == "string" && l.trim() && (e.vae = l), n("vae", i.vae);
let u = i.models;
u && typeof u == "object" && (e.models = u);
let d = Array.isArray(i.model_groups) ? i.model_groups : null;
d && d.length && (e.model_groups = d);
let f = Array.isArray(i.loras) ? i.loras : null;
f && (e.loras = f), f?.some((e) => e?.confidence === "override" || e?.source === "majoor_geninfo") && t.add("loras");
let p = i.sampler?.name ?? i.sampler ?? null;
typeof p == "string" && p.trim() && (e.sampler = p), n("sampler", i.sampler);
let m = i.scheduler?.name ?? i.scheduler ?? null;
typeof m == "string" && m.trim() && (e.scheduler = m), n("scheduler", i.scheduler);
let h = i.steps?.value ?? i.steps ?? null;
h != null && (e.steps = h), n("steps", i.steps);
let g = i.cfg?.value ?? i.cfg ?? null;
g != null && (e.cfg = g), n(["cfg", "cfg_scale"], i.cfg);
let _ = i.cfg_high_noise?.value ?? i.cfg_high_noise ?? null;
_ != null && (e.cfg_high_noise = _);
let v = i.cfg_low_noise?.value ?? i.cfg_low_noise ?? null;
v != null && (e.cfg_low_noise = v);
let y = i.seed?.value ?? i.seed ?? null;
y != null && (e.seed = y), n("seed", i.seed);
let b = i.voice?.name ?? i.voice?.value ?? i.voice ?? null;
b != null && String(b).trim() && (e.voice = String(b).trim());
let x = i.language?.value ?? i.language ?? null;
x != null && String(x).trim() && (e.language = String(x).trim());
let S = i.temperature?.value ?? i.temperature ?? null;
S != null && (e.temperature = S);
let C = i.top_k?.value ?? i.top_k ?? null;
C != null && (e.top_k = C);
let w = i.top_p?.value ?? i.top_p ?? null;
w != null && (e.top_p = w);
let T = i.repetition_penalty?.value ?? i.repetition_penalty ?? null;
T != null && (e.repetition_penalty = T);
let E = i.max_new_tokens?.value ?? i.max_new_tokens ?? null;
E != null && (e.max_new_tokens = E);
let D = i.device?.value ?? i.device ?? null;
D != null && String(D).trim() && (e.device = String(D).trim());
let O = i.voice_preset?.value ?? i.voice_preset ?? null;
O != null && String(O).trim() && (e.voice_preset = String(O).trim());
let k = i.instruct?.value ?? i.instruct ?? null;
k != null && String(k).trim() && (e.instruct = String(k).trim());
let A = i.dtype?.value ?? i.dtype ?? null;
A != null && String(A).trim() && (e.dtype = String(A).trim());
let j = i.attn_implementation?.value ?? i.attn_implementation ?? null;
j != null && String(j).trim() && (e.attn_implementation = String(j).trim());
let M = i.x_vector_only_mode?.value ?? i.x_vector_only_mode ?? null;
M != null && (e.x_vector_only_mode = M);
let N = i.use_torch_compile?.value ?? i.use_torch_compile ?? null;
N != null && (e.use_torch_compile = N);
let P = i.use_cuda_graphs?.value ?? i.use_cuda_graphs ?? null;
P != null && (e.use_cuda_graphs = P);
let F = i.compile_mode?.value ?? i.compile_mode ?? null;
F != null && String(F).trim() && (e.compile_mode = String(F).trim());
let I = i.enable_chunking?.value ?? i.enable_chunking ?? null;
I != null && (e.enable_chunking = I);
let L = i.max_chars_per_chunk?.value ?? i.max_chars_per_chunk ?? null;
L != null && (e.max_chars_per_chunk = L);
let R = i.chunk_combination_method?.value ?? i.chunk_combination_method ?? null;
R != null && String(R).trim() && (e.chunk_combination_method = String(R).trim());
let z = i.silence_between_chunks_ms?.value ?? i.silence_between_chunks_ms ?? null;
z != null && (e.silence_between_chunks_ms = z);
let B = i.enable_audio_cache?.value ?? i.enable_audio_cache ?? null;
B != null && (e.enable_audio_cache = B);
let V = i.batch_size?.value ?? i.batch_size ?? null;
V != null && (e.batch_size = V);
let H = i.denoise?.value ?? i.denoise ?? null;
H != null && (e.denoise = H), n(["denoise", "denoising"], i.denoise);
let W = i.clip_skip?.value ?? i.clip_skip ?? i.clipSkip ?? null;
W != null && (e.clip_skip = W);
let G = i.inputs;
Array.isArray(G) && (e.inputs = G), i.ltx_director && typeof i.ltx_director == "object" && (e.ltx_director = i.ltx_director);
let J = i.lyrics?.value ?? i.lyrics ?? null;
typeof J == "string" && J.trim() && (e.lyrics = J);
let Y = i.lyrics_strength?.value ?? i.lyrics_strength ?? null;
Y != null && (e.lyrics_strength = Y);
let X = i.notes?.value ?? i.workflow_notes?.value ?? i.notes ?? i.workflow_notes ?? null;
typeof X == "string" && X.trim() && (e.workflow_notes = X.trim()), n(["workflow_notes", "notes"], i.notes ?? i.workflow_notes), Array.isArray(i.custom_info) && (e.custom_info = i.custom_info), Array.isArray(i.custom_info) && i.engine?.mode === "override" && t.add("custom_info"), Array.isArray(i.all_positive_prompts) && i.all_positive_prompts.length > 1 && (e.all_positive_prompts = i.all_positive_prompts), Array.isArray(i.all_negative_prompts) && i.all_negative_prompts.length > 1 && (e.all_negative_prompts = i.all_negative_prompts), Array.isArray(i.all_samplers) && i.all_samplers.length > 1 && (e.all_samplers = i.all_samplers), Array.isArray(i.chained_passes) && i.chained_passes.length > 1 && (e.chained_passes = i.chained_passes), Array.isArray(i.all_checkpoints) && i.all_checkpoints.length > 1 && (e.all_checkpoints = i.all_checkpoints);
let Z = i.size || null;
if (Z && typeof Z == "object" && (Z.width !== void 0 && (e.width = Z.width), Z.height !== void 0 && (e.height = Z.height)), i.engine && typeof i.engine == "object" && (e.engine = i.engine, (i.engine.mode === "override" || i.engine.parser_version === "geninfo-override-v1" || i.engine.source === "majoor_geninfo") && (e.is_override = !0)), t.size && (e.override_fields = Array.from(t)), U(e, K(r)), q(e, r), Object.keys(e).length) return e;
}
let a = n(r);
if (a) return a;
if (t(r)) {
let e = n({ prompt: r });
if (e) return e;
}
let o = K(r);
if (o) return o;
if ([
"prompt",
"negative_prompt",
"negativePrompt",
"steps",
"sampler",
"sampler_name",
"cfg",
"cfg_scale",
"seed",
"width",
"height"
].some((e) => Object.prototype.hasOwnProperty.call(r, e))) return r;
let s = r.parameters || r["PNG:Parameters"] || r["EXIF:UserComment"] || r.UserComment || r.ImageDescription || null;
if (typeof s == "string") {
let t = e(s);
if (t) return t;
}
let c = r.workflow || r.Workflow || r.comfy || r.comfyui || r.ComfyUI || null;
if (c && typeof c == "object") {
let e = n(c);
if (e) return e;
}
return r;
}
if (typeof r == "string") {
let t = r.trim();
if (!t) return null;
let n = e(t);
if (n) return n;
if (t.startsWith("{") && t.endsWith("}") || t.startsWith("[") && t.endsWith("]")) try {
return R(JSON.parse(t));
} catch {
return null;
}
return { prompt: t };
}
return null;
}
function z(e) {
return String(e || "").trim().replace(/\\/g, "/").toLowerCase();
}
function B(e) {
if (!e) return null;
if (typeof e == "object") return e;
if (typeof e != "string") return null;
let t = e.trim();
if (!t || !(t.startsWith("{") || t.startsWith("["))) return null;
try {
return JSON.parse(t);
} catch {
return null;
}
}
function V(e) {
if (!e || typeof e != "object") return [];
let t = [], n = (e) => {
e && typeof e == "object" && !t.includes(e) && t.push(e);
};
n(e), n(e.metadata_raw), n(e.metadata), n(e.raw), n(e.exif), n(e.metadata_raw?.exif), n(e.metadata?.exif), n(e.format?.tags), n(e.ffprobe?.format?.tags), n(e.raw_ffprobe?.format?.tags), n(e.metadata_raw?.raw_ffprobe?.format?.tags), n(e.metadata_raw?.ffprobe?.format?.tags), n(e.metadata?.raw_ffprobe?.format?.tags), n(e.metadata?.ffprobe?.format?.tags);
let r = [
e.streams,
e.ffprobe?.streams,
e.raw_ffprobe?.streams,
e.metadata_raw?.raw_ffprobe?.streams,
e.metadata_raw?.ffprobe?.streams,
e.metadata?.raw_ffprobe?.streams,
e.metadata?.ffprobe?.streams
];
for (let e of r) if (Array.isArray(e)) for (let t of e) n(t?.tags);
return t;
}
function H(e, t) {
if (!e || typeof e != "object") return null;
for (let [n, r] of Object.entries(e)) if (t.has(z(n))) return r;
return null;
}
function U(e, t) {
if (t && typeof t == "object") for (let [n, r] of Object.entries(t)) r != null && r !== "" && (Array.isArray(r) && Array.isArray(e[n]) ? e[n] = G(e[n], r) : r && typeof r == "object" && !Array.isArray(r) && e[n] && typeof e[n] == "object" && !Array.isArray(e[n]) ? e[n] = {
...r,
...e[n]
} : (e[n] === void 0 || e[n] === null || e[n] === "") && (e[n] = r));
}
function W(e) {
if (!e || typeof e != "object") return JSON.stringify(e);
let t = e.name || e.lora_name || e.model || e.sampler_name || e.sampler || "", n = e.key || e.pass_stage || e.source || "";
return t || n ? `${String(n).toLowerCase()}::${String(t).toLowerCase()}` : JSON.stringify(e);
}
function G(e, t) {
let n = [...e], r = new Set(n.map((e) => W(e)));
for (let e of t) {
let t = W(e);
r.has(t) || (r.add(t), n.push(e));
}
return n;
}
function K(e) {
let r = null, i = null;
for (let t of V(e)) if (r ||= B(H(t, I)), i ||= B(H(t, L)), r && i) break;
let a = {};
return (r || i) && (U(a, n({
workflow: r,
prompt: i
})), U(a, n(r)), i && t(i) && U(a, n({ prompt: i }))), Object.keys(a).length ? a : null;
}
function q(e, t) {
let r = t?.workflow || t?.Workflow || t?.comfy_workflow || t?.comfy || t?.comfyui || t?.ComfyUI || null;
if (!r || typeof r != "object") return;
let i = n(r), a = Array.isArray(i?.all_samplers) ? i.all_samplers : [];
if (a.length) {
for (let t of ["all_samplers", "chained_passes"]) {
let n = Array.isArray(e[t]) ? e[t] : [];
n.length && (e[t] = n.map((e, t) => {
let n = a[t]?.pass_stage;
return !n || e?.pass_stage ? e : {
...e,
pass_stage: n
};
}));
}
!Array.isArray(e.all_samplers) && a.length > 1 && (e.all_samplers = a);
}
}
function J(e) {
let t = typeof e == "string" ? e.trim() : "";
return !t || t.includes("\n") ? !1 : P.test(t) || F.test(t);
}
function Y(e) {
let t = typeof e == "string" ? e.trim() : "";
return t ? J(t) ? "" : t : "";
}
function X(e, t) {
let n = Y(e), r = Y(t);
if (n) {
let e = /(?:^|\n)\s*Negative prompt:\s*/i;
if (e.test(n)) {
let t = n.split(e), i = (t[0] || "").trim(), a = t.slice(1).join("Negative prompt:").trim(), o = a.search(/\n\s*Steps:\s*\d+/i), s = (o >= 0 ? a.slice(0, o) : a).trim();
i && (n = Y(i)), !r && s && (r = Y(s));
}
}
return n && r && n.trim() === r.trim() && (r = ""), {
positive: n,
negative: r
};
}
function Z(e) {
if (!e) return "";
let t = String(e).trim().replace(/\\/g, "/");
return (t.split("/").pop() || t).replace(/\.(safetensors|ckpt|pt|pth|bin|gguf|json)$/i, "");
}
function Q(e) {
let t = Number(e);
return Number.isFinite(t) ? Math.abs(t - Math.round(t)) < 1e-9 ? String(Math.round(t)) : t.toFixed(2).replace(/0+$/g, "").replace(/\.$/, "") : String(e ?? "").trim();
}
function $(e) {
if (!e) return "";
if (typeof e == "string") return Z(e);
let t = Z(e.name || e.lora_name || "");
if (!t) return "";
let n = e.weight ?? e.strength ?? null, r = e.strength_model ?? null, i = e.strength_clip ?? null;
if (r !== null || i !== null) {
let e = [];
return r != null && e.push(`m=${Q(r)}`), i != null && e.push(`c=${Q(i)}`), e.length ? `${t} (${e.join(", ")})` : t;
}
return n == null ? t : `${t} (${Q(n)})`;
}
function ee(e) {
let t = String(e || "").trim().toLowerCase();
return t ? t === "img2vid" ? "Image-to-Video" : t === "txt2vid" ? "Text-to-Video" : t === "img2img" ? "Image-to-Image" : t === "txt2img" ? "Text-to-Image" : t === "vid2vid" ? "Video-to-Video" : t === "tts" ? "Text-to-Speech" : t === "audio" ? "Audio" : t.split(/[_\s-]+/).filter(Boolean).map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" ") : "";
}
function te(e) {
let t = String(e || "").trim().toLowerCase();
if (!t || t === "api") return "";
let n = {
happy_horse: "Happy Horse",
google_gemini: "Google Gemini",
google_veo: "Google Veo",
openai: "OpenAI",
anthropic: "Anthropic",
black_forest_labs: "Black Forest Labs",
stability_ai: "Stability AI",
alibaba_wan: "Alibaba Wan",
kling_ai: "Kling AI",
luma_dream_machine: "Luma Dream Machine",
minimax_hailuo: "MiniMax Hailuo",
xai_grok: "xAI Grok",
ltxv_api: "LTXV API",
eleven_labs: "ElevenLabs",
bytedance_seedance: "ByteDance Seedance"
};
return n[t] ? n[t] : t.split(/[_\s-]+/).filter(Boolean).map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" ");
}
function ne(e) {
let t = e?.engine && typeof e.engine == "object" ? e.engine : null, n = String(t?.type || "").trim(), r = String(t?.sampler_mode || "").trim().toLowerCase(), i = ee(n) || n, a = te(t?.api_provider), o = r === "api" ? `API ${i || "Workflow"}` : i;
return {
workflowType: n,
workflowLabel: String(o || n).trim(),
workflowBadge: a
};
}
export { X as a, R as i, $ as n, Y as o, Z as r, ne as t };
@@ -27,8 +27,6 @@ var e = {
"setting.nav.infinite.desc": "滚动时自动加载更多文件。",
"setting.grid.pagesize.name": "主要:网格页面大小",
"setting.grid.pagesize.desc": "网格中每页/请求加载的资源数量。",
"setting.viewer.pan.name": "主要:平移而不缩放",
"setting.viewer.pan.desc": "即使在缩放级别 1 下也允许平移图像。",
"setting.minimap.enabled.name": "主要:启用小地图",
"setting.minimap.enabled.desc": "工作流程小地图的全局激活。",
"setting.scan.startup.name": "主要:启动时自动扫描",
@@ -184,10 +182,8 @@ var e = {
"msg.errorLoading": "加载错误",
"msg.errorLoadingFolders": "加载文件夹时出错",
"msg.noGenerationData": "找不到该文件的生成数据。",
"msg.rawMetadata": "原始元数据",
"viewer.genInfo": "一代信息",
"viewer.workflow": "工作流程",
"viewer.metadata": "元数据",
"viewer.noWorkflow": "无工作流程数据",
"viewer.noMetadata": "没有可用的元数据",
"viewer.copySuccess": "已复制到剪贴板!",
@@ -337,8 +333,6 @@ var e = {
"setting.nav.infinite.desc": "スクロール時にさらに多くのファイルを自動的にロードします。",
"setting.grid.pagesize.name": "メジャー: グリッド ページ サイズ",
"setting.grid.pagesize.desc": "グリッド内のページ/リクエストごとにロードされるアセットの数。",
"setting.viewer.pan.name": "メジャー: ズームなしのパン",
"setting.viewer.pan.desc": "ズーム レベル 1 であっても画像のパンを許可します。",
"setting.minimap.enabled.name": "メジャー: ミニマップを有効にする",
"setting.minimap.enabled.desc": "ワークフロー ミニマップのグローバルなアクティブ化。",
"setting.scan.startup.name": "メジャー: 起動時に自動スキャン",
@@ -494,10 +488,8 @@ var e = {
"msg.errorLoading": "読み込みエラー",
"msg.errorLoadingFolders": "フォルダーの読み込みエラー",
"msg.noGenerationData": "このファイルの世代データは見つかりませんでした。",
"msg.rawMetadata": "生のメタデータ",
"viewer.genInfo": "世代情報",
"viewer.workflow": "ワークフロー",
"viewer.metadata": "メタデータ",
"viewer.noWorkflow": "ワークフローデータがありません",
"viewer.noMetadata": "利用可能なメタデータがありません",
"viewer.copySuccess": "クリップボードにコピーされました!",
@@ -647,8 +639,6 @@ var e = {
"setting.nav.infinite.desc": "스크롤할 때 자동으로 더 많은 파일을 로드합니다.",
"setting.grid.pagesize.name": "전공: 그리드 페이지 크기",
"setting.grid.pagesize.desc": "그리드의 페이지/요청당 로드된 자산 수입니다.",
"setting.viewer.pan.name": "전공: 확대/축소 없이 이동",
"setting.viewer.pan.desc": "확대/축소 수준 1에서도 이미지 패닝을 허용합니다.",
"setting.minimap.enabled.name": "주요: 미니맵 활성화",
"setting.minimap.enabled.desc": "작업흐름 미니맵의 전역 활성화.",
"setting.scan.startup.name": "주요: 시작 시 자동 스캔",
@@ -804,10 +794,8 @@ var e = {
"msg.errorLoading": "로드 중 오류 발생",
"msg.errorLoadingFolders": "폴더를 로드하는 중에 오류가 발생했습니다.",
"msg.noGenerationData": "이 파일에 대한 생성 데이터가 없습니다.",
"msg.rawMetadata": "원시 메타데이터",
"viewer.genInfo": "세대 정보",
"viewer.workflow": "작업흐름",
"viewer.metadata": "메타데이터",
"viewer.noWorkflow": "워크플로 데이터 없음",
"viewer.noMetadata": "사용 가능한 메타데이터가 없습니다.",
"viewer.copySuccess": "클립보드에 복사되었습니다!",
@@ -957,8 +945,6 @@ var e = {
"setting.nav.infinite.desc": "स्क्रॉल करते समय स्वचालित रूप से अधिक फ़ाइलें लोड करें।",
"setting.grid.pagesize.name": "प्रमुख: ग्रिड पृष्ठ का आकार",
"setting.grid.pagesize.desc": "ग्रिड में प्रति पृष्ठ/अनुरोध पर लोड की गई संपत्तियों की संख्या।",
"setting.viewer.pan.name": "मेजर: ज़ूम के बिना पैन",
"setting.viewer.pan.desc": "ज़ूम स्तर 1 पर भी छवि को पैन करने की अनुमति दें।",
"setting.minimap.enabled.name": "मेजर: मिनिमैप सक्षम करें",
"setting.minimap.enabled.desc": "वर्कफ़्लो मिनिमैप का वैश्विक सक्रियण।",
"setting.scan.startup.name": "प्रमुख: स्टार्टअप पर ऑटो-स्कैन",
@@ -1114,10 +1100,8 @@ var e = {
"msg.errorLoading": "लोड होने मे त्रुटि",
"msg.errorLoadingFolders": "फ़ोल्डर लोड करने में त्रुटि",
"msg.noGenerationData": "इस फ़ाइल के लिए कोई पीढ़ी डेटा नहीं मिला.",
"msg.rawMetadata": "कच्चा मेटाडेटा",
"viewer.genInfo": "पीढ़ी की जानकारी",
"viewer.workflow": "कार्यप्रवाह",
"viewer.metadata": "मेटाडाटा",
"viewer.noWorkflow": "कोई वर्कफ़्लो डेटा नहीं",
"viewer.noMetadata": "कोई मेटाडेटा उपलब्ध नहीं है",
"viewer.copySuccess": "क्लिपबोर्ड पर कॉपी किया गया!",
@@ -1267,8 +1251,6 @@ var e = {
"setting.nav.infinite.desc": "Carregue automaticamente mais arquivos ao rolar.",
"setting.grid.pagesize.name": "Principal: Tamanho da página da grade",
"setting.grid.pagesize.desc": "Número de ativos carregados por página/solicitação na grade.",
"setting.viewer.pan.name": "Principal: panorâmica sem zoom",
"setting.viewer.pan.desc": "Permitir deslocar a imagem mesmo no nível de zoom 1.",
"setting.minimap.enabled.name": "Principal: Habilitar Minimapa",
"setting.minimap.enabled.desc": "Ativação global do minimapa do fluxo de trabalho.",
"setting.scan.startup.name": "Principal: Verificação automática na inicialização",
@@ -1424,10 +1406,8 @@ var e = {
"msg.errorLoading": "Erro ao carregar",
"msg.errorLoadingFolders": "Erro ao carregar pastas",
"msg.noGenerationData": "Nenhum dado de geração encontrado para este arquivo.",
"msg.rawMetadata": "Metadados brutos",
"viewer.genInfo": "Informações de geração",
"viewer.workflow": "Fluxo de trabalho",
"viewer.metadata": "Metadados",
"viewer.noWorkflow": "Nenhum dado de fluxo de trabalho",
"viewer.noMetadata": "Nenhum metadado disponível",
"viewer.copySuccess": "Copiado para a área de transferência!",
@@ -1577,8 +1557,6 @@ var e = {
"setting.nav.infinite.desc": "Cargue automáticamente más archivos al desplazarse.",
"setting.grid.pagesize.name": "Mayor: Tamaño de página de cuadrícula",
"setting.grid.pagesize.desc": "Número de recursos cargados por página/solicitud en la cuadrícula.",
"setting.viewer.pan.name": "Mayor: panorámica sin zoom",
"setting.viewer.pan.desc": "Permita desplazar la imagen incluso en el nivel de zoom 1.",
"setting.minimap.enabled.name": "Mayor: habilitar minimapa",
"setting.minimap.enabled.desc": "Activación global del minimapa del flujo de trabajo.",
"setting.scan.startup.name": "Majoor: Escaneo automático al iniciar",
@@ -1734,10 +1712,8 @@ var e = {
"msg.errorLoading": "Error al cargar",
"msg.errorLoadingFolders": "Error al cargar carpetas",
"msg.noGenerationData": "No se encontraron datos de generación para este archivo.",
"msg.rawMetadata": "Metadatos sin procesar",
"viewer.genInfo": "Información de generación",
"viewer.workflow": "Flujo de trabajo",
"viewer.metadata": "Metadatos",
"viewer.noWorkflow": "Sin datos de flujo de trabajo",
"viewer.noMetadata": "No hay metadatos disponibles",
"viewer.copySuccess": "¡Copiado al portapapeles!",
@@ -1887,8 +1863,6 @@ var e = {
"setting.nav.infinite.desc": "Автоматически загружать больше файлов при прокрутке.",
"setting.grid.pagesize.name": "Основное: Размер страницы сетки",
"setting.grid.pagesize.desc": "Количество ресурсов, загруженных на страницу/запрос в сетке.",
"setting.viewer.pan.name": "Мажор: Панорамирование без масштабирования",
"setting.viewer.pan.desc": "Разрешить панорамирование изображения даже при уровне масштабирования 1.",
"setting.minimap.enabled.name": "Майор: включить миникарту",
"setting.minimap.enabled.desc": "Глобальная активация миникарты рабочего процесса.",
"setting.scan.startup.name": "Основное: автоматическое сканирование при запуске.",
@@ -2044,10 +2018,8 @@ var e = {
"msg.errorLoading": "Ошибка загрузки",
"msg.errorLoadingFolders": "Ошибка загрузки папок.",
"msg.noGenerationData": "Для этого файла не найдены данные о создании.",
"msg.rawMetadata": "Необработанные метаданные",
"viewer.genInfo": "Информация о поколении",
"viewer.workflow": "Рабочий процесс",
"viewer.metadata": "Метаданные",
"viewer.noWorkflow": "Нет данных о рабочем процессе",
"viewer.noMetadata": "Нет доступных метаданных",
"viewer.copySuccess": "Скопировано в буфер обмена!",
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import { $ as e, F as t, I as n, K as r, L as i, Q as a, T as o, X as s, Z as c, et as l, nt as u, ot as d, rt as f, st as p, tt as m } from "./mjr-primevue-BRqtdXzb.js";
import { B as e, L as t, R as n, T as r, X as i, at as a, dt as o, et as s, it as c, nt as l, ot as u, rt as d, st as f, tt as p, ut as m } from "./mjr-primevue-C07IsEdU.js";
// node_modules/pinia/dist/pinia.js
var h = typeof window < "u", g, _ = (e) => g = e, v = () => t() && n(y) || g, y = Symbol();
function b(e) {
@@ -86,7 +86,7 @@ function j(e, t, n, r) {
}
var { assign: M } = Object;
function N() {
let e = s(!0), t = e.run(() => f({})), n = [], r = [], i = l({
let e = s(!0), t = e.run(() => f({})), n = [], r = [], i = c({
install(e) {
_(i), i._a = e, e.provide(y, i), e.config.globalProperties.$pinia = i, r.forEach((e) => n.push(e)), r = [];
},
@@ -107,7 +107,7 @@ function F(e, t, n, r = P) {
let i = () => {
e.delete(t) && r();
};
return !n && c() && m(i), i;
return !n && p() && a(i), i;
}
function I(e, ...t) {
e.forEach((e) => {
@@ -115,136 +115,136 @@ function I(e, ...t) {
});
}
var L = (e) => e(), R = Symbol(), z = Symbol();
function B(t, n) {
t instanceof Map && n instanceof Map ? n.forEach((e, n) => t.set(n, e)) : t instanceof Set && n instanceof Set && n.forEach(t.add, t);
for (let r in n) {
if (!Object.hasOwn(n, r)) continue;
let i = n[r], o = t[r];
t[r] = b(o) && b(i) && Object.hasOwn(t, r) && !e(i) && !a(i) ? B(o, i) : i;
function B(e, t) {
e instanceof Map && t instanceof Map ? t.forEach((t, n) => e.set(n, t)) : e instanceof Set && t instanceof Set && t.forEach(e.add, e);
for (let n in t) {
if (!Object.hasOwn(t, n)) continue;
let r = t[n], i = e[n];
e[n] = b(i) && b(r) && Object.hasOwn(e, n) && !d(r) && !l(r) ? B(i, r) : r;
}
return t;
return e;
}
var V = Symbol();
function H(e) {
return !e || typeof e != "object" || !Object.hasOwn(e, V);
}
var { assign: U } = Object;
function W(t) {
return !!(e(t) && t.effect);
function W(e) {
return !!(d(e) && e.effect);
}
function G(e, t, n, r) {
let { state: i, actions: a, getters: s } = t, c = n.state.value[e], u;
function d() {
return c ||
function G(e, t, n, i) {
let { state: a, actions: s, getters: l } = t, u = n.state.value[e], d;
function f() {
return u ||
/* istanbul ignore if */
(n.state.value[e] = i ? i() : {}), U(p(n.state.value[e]), a, Object.keys(s || {}).reduce((t, r) => (t[r] = l(o(() => {
(n.state.value[e] = a ? a() : {}), U(o(n.state.value[e]), s, Object.keys(l || {}).reduce((t, i) => (t[i] = c(r(() => {
_(n);
let t = n._s.get(e);
return s[r].call(t, t);
return l[i].call(t, t);
})), t), {}));
}
return u = K(e, d, t, n, r, !0), u;
return d = K(e, f, t, n, i, !0), d;
}
function K(t, n, o = {}, c, l, f) {
let p, m = U({ actions: {} }, o), h = { deep: !0 }, g, v, y = /* @__PURE__ */ new Set(), b = /* @__PURE__ */ new Set(), x, S = c.state.value[t];
!f && !S &&
function K(t, n, r = {}, a, o, c) {
let f, p = U({ actions: {} }, r), h = { deep: !0 }, g, v, y = /* @__PURE__ */ new Set(), b = /* @__PURE__ */ new Set(), x, S = a.state.value[t];
!c && !S &&
/* istanbul ignore if */
(c.state.value[t] = {});
(a.state.value[t] = {});
let C;
function w(e) {
let n;
g = v = !1, typeof e == "function" ? (e(c.state.value[t]), n = {
function w(n) {
let r;
g = v = !1, typeof n == "function" ? (n(a.state.value[t]), r = {
type: "patch function",
storeId: t,
events: x
}) : (B(c.state.value[t], e), n = {
}) : (B(a.state.value[t], n), r = {
type: "patch object",
payload: e,
payload: n,
storeId: t,
events: x
});
let r = C = Symbol();
i().then(() => {
C === r && (g = !0);
}), v = !0, I(y, n, c.state.value[t]);
let i = C = Symbol();
e().then(() => {
C === i && (g = !0);
}), v = !0, I(y, r, a.state.value[t]);
}
let T = f ? function() {
let { state: e } = o, t = e ? e() : {};
let T = c ? function() {
let { state: e } = r, t = e ? e() : {};
this.$patch((e) => {
U(e, t);
});
} : P;
function E() {
p.stop(), y.clear(), b.clear(), c._s.delete(t);
f.stop(), y.clear(), b.clear(), a._s.delete(t);
}
let D = (e, n = "") => {
if (R in e) return e[z] = n, e;
let r = function() {
_(c);
let n = Array.from(arguments), i = /* @__PURE__ */ new Set(), a = /* @__PURE__ */ new Set();
function o(e) {
_(a);
let n = Array.from(arguments), i = /* @__PURE__ */ new Set(), o = /* @__PURE__ */ new Set();
function s(e) {
i.add(e);
}
function s(e) {
a.add(e);
function c(e) {
o.add(e);
}
I(b, {
args: n,
name: r[z],
store: k,
after: o,
onError: s
after: s,
onError: c
});
let l;
try {
l = e.apply(this && this.$id === t ? this : k, n);
} catch (e) {
throw I(a, e), e;
throw I(o, e), e;
}
return l instanceof Promise ? l.then((e) => (I(i, e), e)).catch((e) => (I(a, e), Promise.reject(e))) : (I(i, l), l);
return l instanceof Promise ? l.then((e) => (I(i, e), e)).catch((e) => (I(o, e), Promise.reject(e))) : (I(i, l), l);
};
return r[R] = !0, r[z] = n, r;
}, O = {
_p: c,
_p: a,
$id: t,
$onAction: F.bind(null, b),
$patch: w,
$reset: T,
$subscribe(e, n = {}) {
if (y.has(e)) return P;
let i = F(y, e, n.detached, () => a()), a = p.run(() => r(() => c.state.value[t], (r) => {
let r = F(y, e, n.detached, () => o()), o = f.run(() => i(() => a.state.value[t], (r) => {
(n.flush === "sync" ? v : g) && e({
storeId: t,
type: "direct",
events: x
}, r);
}, U({}, h, n)));
return i;
return r;
},
$dispose: E
}, k = u(O);
c._s.set(t, k);
let A = (c._a && c._a.runWithContext || L)(() => c._e.run(() => (p = s()).run(() => n({ action: D }))));
for (let n in A) {
let r = A[n];
e(r) && !W(r) || a(r) ? f || (S && H(r) && (e(r) ? r.value = S[n] : ((r instanceof Set || r instanceof Map) && r.clear(), B(r, S[n]))), c.state.value[t][n] = r) : typeof r == "function" && (A[n] = D(r, n), m.actions[n] = r);
a._s.set(t, k);
let A = (a._a && a._a.runWithContext || L)(() => a._e.run(() => (f = s()).run(() => n({ action: D }))));
for (let e in A) {
let n = A[e];
d(n) && !W(n) || l(n) ? c || (S && H(n) && (d(n) ? n.value = S[e] : ((n instanceof Set || n instanceof Map) && n.clear(), B(n, S[e]))), a.state.value[t][e] = n) : typeof n == "function" && (A[e] = D(n, e), p.actions[e] = n);
}
return U(k, A), U(d(k), A), Object.defineProperty(k, "$state", {
get: () => c.state.value[t],
return U(k, A), U(m(k), A), Object.defineProperty(k, "$state", {
get: () => a.state.value[t],
set: (e) => {
w((t) => {
U(t, e);
});
}
}), c._p.forEach((e) => {
let t = p.run(() => e({
}), a._p.forEach((e) => {
let t = f.run(() => e({
store: k,
app: c._a,
pinia: c,
options: m
app: a._a,
pinia: a,
options: p
}));
U(k, t);
}), S && f && o.hydrate && o.hydrate(k.$state, S), g = !0, v = !0, k;
}), S && c && r.hydrate && r.hydrate(k.$state, S), g = !0, v = !0, k;
}
function q(e, r, i) {
let a, o = typeof r == "function";
File diff suppressed because it is too large Load Diff
-187
View File
@@ -1,187 +0,0 @@
import { n as e } from "./state-D0Gu1AgG.js";
// ui/features/viewer/scopes.ts
function t(e, t, n) {
return .2126 * e + .7152 * t + .0722 * n;
}
var n = 256, r = 3, i = 220, a = 110, o = 2, s = 10, c = 1, l = "12px var(--comfy-font, ui-sans-serif, system-ui)", u = 10, d = 8, f = 10, p = 28, m = 10, h = 1.2, g = 1, _ = 420, v = 240, y = 32, b = 24, x = .06, S = .9, C = .9, w = null, T = null;
function E(e, t) {
try {
let n = Math.max(1, Math.floor(Number(e) || 1)), r = Math.max(1, Math.floor(Number(t) || 1));
return w ||= document.createElement("canvas"), w.width !== n && (w.width = n), w.height !== r && (w.height = r), T ||= w.getContext("2d", { willReadFrequently: !0 }), T || null;
} catch {
return null;
}
}
function D(e, t, n) {
try {
return e.getImageData(0, 0, t, n);
} catch {
return null;
}
}
function O(e, { x: t, y: n, w: r, h: i, title: a }) {
try {
e.save(), e.fillStyle = "rgba(0,0,0,0.55)", e.strokeStyle = "rgba(255,255,255,0.14)", e.lineWidth = c;
let o = s;
e.beginPath(), e.moveTo(t + o, n), e.arcTo(t + r, n, t + r, n + i, o), e.arcTo(t + r, n + i, t, n + i, o), e.arcTo(t, n + i, t, n, o), e.arcTo(t, n, t + r, n, o), e.closePath(), e.fill(), e.stroke(), a && (e.font = l, e.textAlign = "left", e.textBaseline = "top", e.fillStyle = "rgba(255,255,255,0.86)", e.fillText(String(a), t + u, n + d)), e.restore();
} catch (e) {
console.debug?.(e);
}
}
function k(e, { sampleStep: i = r } = {}) {
let a = n, o = new Uint32Array(a), s = new Uint32Array(a), c = new Uint32Array(a), l = new Uint32Array(a);
try {
let n = e?.data;
if (!n) return {
hr: o,
hg: s,
hb: c,
hl: l,
max: 1
};
let r = Math.max(1, Math.floor(i));
for (let e = 0; e < n.length; e += 4 * r) {
let r = n[e] ?? 0, i = n[e + 1] ?? 0, a = n[e + 2] ?? 0;
o[r] += 1, s[i] += 1, c[a] += 1;
let u = Math.max(0, Math.min(255, Math.round(t(r, i, a))));
l[u] += 1;
}
let u = 1;
for (let e = 0; e < a; e++) u = Math.max(u, o[e], s[e], c[e], l[e]);
return {
hr: o,
hg: s,
hb: c,
hl: l,
max: u
};
} catch {
return {
hr: o,
hg: s,
hb: c,
hl: l,
max: 1
};
}
}
function A(e, t, r, { channel: i = "rgb" } = {}) {
try {
let { x: a, y: o, w: s, h: l } = t, u = p, d = f, _ = a + d, v = o + u, y = s - 20, b = l - u - d;
if (!(y > m && b > m)) return;
e.save(), e.globalCompositeOperation = "source-over", e.strokeStyle = "rgba(255,255,255,0.10)", e.lineWidth = c, e.beginPath(), e.rect(_, v, y, b), e.stroke();
let x = Number(r?.max) || 1, S = (t, r) => {
e.strokeStyle = r, e.beginPath();
for (let r = 0; r < n; r++) {
let n = (t?.[r] || 0) / x, i = _ + r / 255 * y, a = v + b - n * b;
r === 0 ? e.moveTo(i, a) : e.lineTo(i, a);
}
e.stroke();
}, C = String(i || "rgb");
e.lineWidth = h, C === "r" ? S(r.hr, "rgba(255,90,90,0.95)") : C === "g" ? S(r.hg, "rgba(90,255,140,0.95)") : C === "b" ? S(r.hb, "rgba(90,160,255,0.95)") : C === "l" ? S(r.hl, "rgba(255,210,90,0.95)") : (S(r.hr, "rgba(255,90,90,0.95)"), S(r.hg, "rgba(90,255,140,0.95)"), S(r.hb, "rgba(90,160,255,0.95)")), C !== "l" && (e.lineWidth = g, S(r.hl, "rgba(255,255,255,0.45)")), e.restore();
} catch (e) {
console.debug?.(e);
}
}
function j(n, { columns: r = i, rows: s = a, sampleStep: c = o } = {}) {
let l = Math.max(y, Math.floor(r)), u = Math.max(b, Math.floor(s)), d = new Uint16Array(l * u);
try {
let r = n?.data, i = n?.width || 0, a = n?.height || 0;
if (!r || !(i > 0 && a > 0)) return {
out: d,
gridW: l,
gridH: u,
max: 1
};
let o = Math.max(1, Math.floor(c));
for (let n = 0; n < a; n += o) {
let a = n * i * 4;
for (let n = 0; n < i; n += o) {
let o = n / (i - 1 || 1), s = Math.max(0, Math.min(l - 1, Math.floor(o * (l - 1)))), c = a + n * 4, f = r[c] ?? 0, p = r[c + 1] ?? 0, m = r[c + 2] ?? 0, h = e(t(f / 255, p / 255, m / 255)), g = Math.max(0, Math.min(u - 1, Math.round((1 - h) * (u - 1))));
d[g * l + s] += 1;
}
}
let s = 1;
for (let e = 0; e < d.length; e++) s = Math.max(s, d[e]);
return {
out: d,
gridW: l,
gridH: u,
max: s
};
} catch {
return {
out: d,
gridW: l,
gridH: u,
max: 1
};
}
}
function M(e, t, n) {
try {
let { x: r, y: i, w: a, h: o } = t, s = p, l = f, u = r + l, d = i + s, h = a - 20, g = o - s - l;
if (!(h > m && g > m)) return;
e.save(), e.strokeStyle = "rgba(255,255,255,0.10)", e.lineWidth = c, e.beginPath(), e.rect(u, d, h, g), e.stroke();
let _ = n?.gridW || 0, v = n?.gridH || 0, y = n?.out, b = Number(n?.max) || 1;
if (!y || !(_ > 0 && v > 0)) return;
for (let t = 0; t < v; t++) for (let n = 0; n < _; n++) {
let r = (y[t * _ + n] || 0) / b;
if (r <= 0) continue;
let i = u + n / (_ - 1 || 1) * h, a = d + t / (v - 1 || 1) * g;
e.fillStyle = `rgba(255,255,255,${Math.min(S, x + r * C)})`, e.fillRect(i, a, Math.max(1, h / _), Math.max(1, g / v));
}
e.restore();
} catch (e) {
console.debug?.(e);
}
}
function N(e, t, n, r = {}) {
try {
let i = String(r?.mode || "both"), a = String(r?.channel || "rgb");
if (!e || !t || !n || i === "off") return;
let o = Number(n.width) || 0, s = Number(n.height) || 0;
if (!(o > 1 && s > 1)) return;
let c = Math.max(o / _, s / v, 1), l = Math.max(1, Math.floor(o / c)), u = Math.max(1, Math.floor(s / c)), d = E(l, u);
if (!d) return;
try {
d.clearRect(0, 0, l, u), d.drawImage(n, 0, 0, l, u);
} catch {
return;
}
let f = D(d, l, u);
if (!f) return;
let p = Math.min(520, Math.max(320, Math.floor(t.w * .36))), m = Math.min(260, Math.max(160, Math.floor(t.h * .22))), h = t.w - p - 10, g = t.h - m - 10;
if (i === "hist" || i === "both") {
let t = {
x: h,
y: g,
w: p,
h: m
}, n = a === "r" ? "Histogram (R)" : a === "g" ? "Histogram (G)" : a === "b" ? "Histogram (B)" : a === "l" ? "Histogram (Luma)" : "Histogram (RGB + Luma)";
O(e, {
...t,
title: n
}), A(e, t, k(f, { sampleStep: Math.max(2, Math.floor(Math.max(l, u) / 180)) }), { channel: a });
}
if (i === "wave" || i === "both") {
let t = {
x: h,
y: i === "both" ? g - m - 8 : g,
w: p,
h: m
};
O(e, {
...t,
title: "Waveform (Luma)"
}), M(e, t, j(f, {
columns: 180,
rows: 96,
sampleStep: Math.max(2, Math.floor(Math.max(l, u) / 180))
}));
}
} catch (e) {
console.debug?.(e);
}
}
export { N as drawScopesLight };
-256
View File
@@ -1,256 +0,0 @@
import { i as e } from "./model3dRenderer-BnFOXFbn.js";
import { i as t, o as n } from "./geninfoParser-CgHp-y-n.js";
// ui/features/viewer/sideBySide.ts
function r(e) {
if (!e) return null;
try {
let r = e.geninfo ? { geninfo: e.geninfo } : e.metadata || e.metadata_raw || e, i = t(r) || null;
if (i && typeof i == "object") {
let t = {
prompt: n(i.prompt) || (r?.prompt ? n(String(r.prompt)) : "") || "",
seed: i.seed == null ? "" : String(i.seed),
sampler: i.sampler ? String(i.sampler) : "",
scheduler: i.scheduler ? String(i.scheduler) : "",
cfg: i.cfg == null ? "" : String(i.cfg),
step: i.steps == null ? "" : String(i.steps),
genTime: ""
}, a = e.generation_time_ms ?? r?.generation_time_ms ?? 0;
return a && Number.isFinite(Number(a)) && a > 0 && a < 864e5 && (t.genTime = (Number(a) / 1e3).toFixed(1) + "s"), t;
}
} catch {}
let r = e.meta || e.metadata || e.parsed_meta || e, i = e.generation_time_ms ?? r?.generation_time_ms ?? 0;
return {
prompt: n(r?.prompt || r?.text || ""),
seed: r?.seed == null ? r?.noise_seed == null ? "" : String(r.noise_seed) : String(r.seed),
sampler: r?.sampler || r?.sampler_name || "",
scheduler: r?.scheduler || "",
cfg: r?.cfg == null ? r?.cfg_scale == null ? "" : String(r.cfg_scale) : String(r.cfg),
step: r?.steps == null ? "" : String(r.steps),
genTime: i && Number.isFinite(Number(i)) && i > 0 && i < 864e5 ? (Number(i) / 1e3).toFixed(1) + "s" : ""
};
}
function i(e, t) {
let n = r(e);
if (!n) return null;
let { prompt: i, seed: a, sampler: o, scheduler: s, cfg: c, step: l, genTime: u } = n, d = [
a ? `Seed: ${a}` : "",
c ? `CFG: ${c}` : "",
o ? `Sampler: ${o}` : "",
s ? `Sched: ${s}` : "",
l ? `Steps: ${l}` : "",
u ? `Gen: ${u}` : ""
].filter(Boolean).join(" - ");
if (!i && !d) return null;
let f = document.createElement("div");
f.style.cssText = "position:absolute;left:6px;right:6px;bottom:6px;background:rgba(0,0,0,0.68);color:#fff;padding:5px 8px;border-radius:6px;font-size:10px;line-height:1.4;max-height:38%;overflow:hidden;word-break:break-word;backdrop-filter:blur(4px);-webkit-backdrop-filter:blur(4px);border:1px solid rgba(255,255,255,0.12);box-shadow:0 2px 6px rgba(0,0,0,0.45);pointer-events:none;z-index:2;box-sizing:border-box";
let p = i.length > 160 ? i.slice(0, 160) + "..." : i, m = document.createElement("div");
m.style.cssText = `display:flex;align-items:baseline;gap:5px;flex-wrap:wrap;margin-bottom:${d ? "3" : "0"}px;`;
let h = document.createElement("span");
if (h.style.cssText = "background:rgba(255,255,255,0.2);border-radius:3px;padding:0 4px;font-weight:700;font-size:9px;letter-spacing:0.06em;flex-shrink:0;", h.textContent = t, m.appendChild(h), i) {
let e = document.createElement("span"), t = document.createElement("span");
t.style.cssText = "color:#7ec8ff;font-weight:600;", t.textContent = "Prompt:", e.appendChild(t), e.appendChild(document.createTextNode(" " + p)), m.appendChild(e);
}
if (f.appendChild(m), d) {
let e = document.createElement("div");
e.style.cssText = "color:rgba(255,255,255,0.65);font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;", e.textContent = d, f.appendChild(e);
}
return f;
}
function a(e, t) {
let n = 0, r = () => {
n++;
let i = e?.querySelector?.(".mjr-model3d-host"), a = t?.querySelector?.(".mjr-model3d-host"), o = i?._mjr3D?.controls, s = a?._mjr3D?.controls;
if (!o || !s) {
n < 60 && setTimeout(r, 50);
return;
}
let c = !1, l = () => {
if (!c) {
c = !0;
try {
let e = i._mjr3D.camera, t = a._mjr3D.camera;
e && t && (t.position.copy(e.position), t.quaternion.copy(e.quaternion), e.zoom !== void 0 && (t.zoom = e.zoom), t.updateProjectionMatrix()), s.target.copy(o.target), s.update();
} catch (e) {
console.debug?.(e);
}
c = !1;
}
}, u = () => {
if (!c) {
c = !0;
try {
let e = i._mjr3D.camera, t = a._mjr3D.camera;
e && t && (e.position.copy(t.position), e.quaternion.copy(t.quaternion), t.zoom !== void 0 && (e.zoom = t.zoom), e.updateProjectionMatrix()), o.target.copy(s.target), o.update();
} catch (e) {
console.debug?.(e);
}
c = !1;
}
};
o.addEventListener("change", l), s.addEventListener("change", u);
try {
let t = e.parentElement;
t && (t._mjr3DSyncCleanup = () => {
try {
o.removeEventListener("change", l);
} catch {}
try {
s.removeEventListener("change", u);
} catch {}
});
} catch (e) {
console.debug?.(e);
}
};
setTimeout(r, 50);
}
function o(e) {
let t = 0, n = () => {
t++;
let r = Array.from(e?.querySelectorAll?.(".mjr-model3d-host") || []).filter((e) => e._mjr3D?.controls);
if (r.length < 2) {
t < 60 && setTimeout(n, 50);
return;
}
let i = !1, a = [];
for (let e = 0; e < r.length; e++) {
let t = r[e], n = () => {
if (!i) {
i = !0;
try {
let n = t._mjr3D.camera, i = t._mjr3D.controls;
for (let t = 0; t < r.length; t++) {
if (t === e) continue;
let a = r[t], o = a._mjr3D.camera, s = a._mjr3D.controls;
o && s && (o.position.copy(n.position), o.quaternion.copy(n.quaternion), n.zoom !== void 0 && (o.zoom = n.zoom), o.updateProjectionMatrix(), s.target.copy(i.target), s.update());
}
} catch (e) {
console.debug?.(e);
}
i = !1;
}
};
t._mjr3D.controls.addEventListener("change", n), a.push(() => {
try {
t._mjr3D?.controls?.removeEventListener?.("change", n);
} catch {}
});
}
e._mjr3DSyncCleanup = () => {
for (let e of a) e();
};
};
setTimeout(n, 50);
}
function s({ sideView: t, state: n, currentAsset: r, viewUrl: s, buildAssetViewURL: c, createMediaElement: l, destroyMediaProcessorsIn: u } = {}) {
try {
t?._mjr3DSyncCleanup?.();
} catch (e) {
console.debug?.(e);
}
try {
t && (t._mjr3DSyncCleanup = null);
} catch (e) {
console.debug?.(e);
}
try {
u?.(t);
} catch (e) {
console.debug?.(e);
}
try {
t && (t.innerHTML = "");
} catch (e) {
console.debug?.(e);
}
if (!t || !n || !r) return;
let d = (e, t) => {
try {
let n = e?.querySelector?.(".mjr-viewer-video-src") || e?.querySelector?.("video"), r = e?.querySelector?.(".mjr-viewer-audio-src") || e?.querySelector?.("audio");
if (n?.dataset && (n.dataset.mjrCompareRole = t), r?.dataset && (r.dataset.mjrCompareRole = t), r) {
let e = String(t || "").toUpperCase() === "A";
if (r.muted = !e, !e) try {
r.pause?.();
} catch (e) {
console.debug?.(e);
}
}
} catch (e) {
console.debug?.(e);
}
}, f = Array.isArray(n.assets) ? n.assets.slice(0, 4) : [], p = f.length, m = !!n.compareAsset;
if (p > 2 && !m) {
try {
t.style.display = "grid", t.style.gridTemplateColumns = "1fr 1fr", t.style.gridTemplateRows = "1fr 1fr", t.style.gap = "2px", t.style.padding = "2px";
} catch (e) {
console.debug?.(e);
}
let n = [
"A",
"B",
"C",
"D"
];
for (let e = 0; e < 4; e++) {
let r = document.createElement("div");
r.style.cssText = "\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(255,255,255,0.05);\n overflow: hidden;\n ";
let a = f[e];
if (a) {
let t = "";
try {
t = c?.(a) || "";
} catch (e) {
console.debug?.(e);
}
try {
let i = l?.(a, t);
i && r.appendChild(i), d(i, n[e]);
} catch (e) {
console.debug?.(e);
}
try {
let t = i(a, n[e]);
t && r.appendChild(t);
} catch (e) {
console.debug?.(e);
}
} else {
let t = document.createElement("div");
t.style.cssText = "position:absolute;top:6px;left:6px;background:rgba(255,255,255,0.12);border-radius:3px;padding:1px 6px;font-size:9px;font-weight:700;color:rgba(255,255,255,0.4);letter-spacing:0.06em;", t.textContent = n[e], r.appendChild(t);
}
try {
t.appendChild(r);
} catch (e) {
console.debug?.(e);
}
}
f.some((t) => t && e(t)) && o(t);
return;
}
let h = n.compareAsset || (Array.isArray(n.assets) && n.assets.length === 2 ? n.assets[1 - (n.currentIndex || 0)] : null) || r, g = (() => {
try {
return c?.(h);
} catch {
return "";
}
})(), _ = document.createElement("div");
_.style.cssText = "\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(255,255,255,0.05);\n overflow: hidden;\n ";
let v = l?.(r, s);
v && _.appendChild(v);
let y = document.createElement("div");
y.style.cssText = "\n flex: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(255,255,255,0.05);\n overflow: hidden;\n ";
let b = l?.(h, g);
b && y.appendChild(b);
try {
t.style.display = "flex", t.style.flexDirection = "row", t.style.gap = "2px", t.style.padding = "0";
} catch (e) {
console.debug?.(e);
}
try {
t.appendChild(_), t.appendChild(y);
} catch (e) {
console.debug?.(e);
}
d(v, "A"), d(b, "B"), e(r) && e(h) && a(_, y);
}
export { s as renderSideBySideView };
-58
View File
@@ -1,58 +0,0 @@
// ui/features/viewer/state.ts
function e(e) {
let t = Number(e);
return Number.isFinite(t) ? Math.max(0, Math.min(1, t)) : 0;
}
function t(e, t, n) {
let r = Number(e);
return Number.isFinite(r) ? Math.max(Number(t), Math.min(Number(n), r)) : Number(t);
}
function n() {
return {
assets: [],
currentIndex: 0,
mode: "single",
zoom: 1,
panX: 0,
panY: 0,
targetZoom: 1,
compareAsset: null,
channel: "rgb",
exposureEV: 0,
gamma: 1,
analysisMode: "none",
zebraThreshold: .95,
abCompareMode: "wipe",
scopesMode: "off",
gridMode: 0,
overlayMaskEnabled: !1,
overlayMaskOpacity: .65,
overlayFormat: "image",
probeEnabled: !1,
loupeEnabled: !1,
hudEnabled: !0,
distractionFree: !1,
loupeSize: 120,
loupeMagnification: 8,
genInfoOpen: !0,
audioVisualizerMode: "artistic",
_userInteracted: !1,
_panHintAt: 0,
_panHintX: null,
_panHintY: null,
_panHintTimer: null,
_prevBodyOverflow: "",
_prevFocusedElement: null,
_lastPointerX: null,
_lastPointerY: null,
_mediaW: 0,
_mediaH: 0,
_viewportCache: null,
_probe: null,
_videoControlsDestroy: null,
_activeVideoEl: null,
_abWipePercent: 50,
_scopesLastAt: 0
};
}
export { e as n, n as r, t };
File diff suppressed because it is too large Load Diff
-36
View File
@@ -1,36 +0,0 @@
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
import { r as t } from "./events-C65wdOHm.js";
import { t as n } from "./Viewer-W-G40j3K.js";
// ui/features/viewer/viewerOpenRequest.ts
var r = /* @__PURE__ */ e({ requestViewerOpen: () => o });
function i(e) {
let t = String(e || "").trim().toLowerCase();
return t === "ab" || t === "sidebyside" ? t : "";
}
function a(e, t) {
let n = Array.isArray(e) ? e.filter(Boolean) : [];
return n.length ? n : t ? [t] : [];
}
function o({ assets: e = [], asset: r = null, index: o = 0, mode: s = "" } = {}) {
let c = a(e, r);
if (!c.length) return !1;
let l = Math.max(0, Math.min(Number(o) || 0, c.length - 1)), u = i(s), d = {
assets: c,
index: l,
mode: u,
handled: !1
};
try {
if (window.dispatchEvent(new CustomEvent(t.OPEN_VIEWER, { detail: d })), d.handled === !0) return !0;
} catch (e) {
console.debug?.(e);
}
try {
let e = n();
return e.open(c, l), u && e.setMode?.(u), !0;
} catch (e) {
console.debug?.(e);
}
return !1;
}
export { r as n, o as t };
+29
View File
@@ -0,0 +1,29 @@
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
import { r as t } from "./events-1iwo5blr.js";
// ui/features/viewer/viewerOpenRequest.ts
var n = /* @__PURE__ */ e({ requestViewerOpen: () => a });
function r(e) {
let t = String(e || "").trim().toLowerCase();
return t === "ab" || t === "sidebyside" ? t : "";
}
function i(e, t) {
let n = Array.isArray(e) ? e.filter(Boolean) : [];
return n.length ? n : t ? [t] : [];
}
function a({ assets: e = [], asset: n = null, index: a = 0, mode: o = "" } = {}) {
let s = i(e, n);
if (!s.length) return !1;
let c = {
assets: s,
index: Math.max(0, Math.min(Number(a) || 0, s.length - 1)),
mode: r(o),
handled: !1
};
try {
window.dispatchEvent(new CustomEvent(t.OPEN_VIEWER, { detail: c }));
} catch (e) {
console.debug?.(e);
}
return c.handled === !0;
}
export { n, a as t };
File diff suppressed because it is too large Load Diff
+6134 -5901
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -12,6 +12,7 @@
- **Current API**: `/mjr/am/*` (unversioned, stable)
- **Version Alias**: `/mjr/am/v1/*` → redirects to `/mjr/am/*` (308 Permanent Redirect)
- **Legacy Alias**: `/majoor/version` → redirects to `/mjr/am/version`
- **OpenAPI compat (read-only)**: `/mjr/am/compat/assets/*` mirrors the shape of ComfyUI core `/api/assets/*` (`GET /mjr/am/compat/assets`, `/{id}`, `/{id}/content`, `/{id}/tags`, `/tags/refine`, `/seed/status`, `HEAD /hash/{hash}`). The former `/api/v2/assets/*` paths are deprecated and only served when `MJR_AM_LEGACY_API_V2_ASSETS=1`.
### Authentication
Most endpoints require authentication when ComfyUI auth is enabled:
+248
View File
@@ -0,0 +1,248 @@
# Audit d'intégration ComfyUI — novembre 2026
Audit du code backend (aiohttp / PromptServer / nœuds V3) et frontend (extension
Vue/TS) de Majoor Assets Manager v2.5.3, face aux versions récentes de ComfyUI
(core avec API Assets/Jobs, frontend 1.5x–1.60, sous-graphes, nœuds V3).
Objectif : faire évoluer l'extension **sans conflit** avec le core et avec les
autres custom nodes, et s'appuyer sur les API officielles plutôt que sur des
internes.
## 1. État des lieux
Tous les contrôles existants passent sur la branche :
| Contrôle | Résultat |
| --- | --- |
| `tsc -p tsconfig.json` | OK |
| `vitest run` | 157 fichiers, 858 tests OK |
| `pytest` | 1931 OK, 2 ignorés (torch absent) |
| `ruff` | 5 erreurs d'ordre d'imports → corrigées |
L'architecture d'intégration est déjà saine :
- **Frontend** : une couche unique `ui/app/hostAdapter.ts` → `comfyApiBridge.ts`
(ADR 0007) isole toutes les API ComfyUI ; commandes, raccourcis, menus
(`menuCommands`, `getCanvasMenuItems`, `getNodeMenuItems`,
`getSelectionToolboxCommands`), onglets latéral/bas et toasts passent par
l'API déclarative d'extension. Les ID de nœuds de sous-graphes (`"12:5"`) sont
résolus par `graphTraversal.findGraphNodeById`. Les écouteurs
`execution_start/success/error/interrupted` sont tous enregistrés et retirés.
- **Backend** : `adapters/comfy_core.py` sonde les capacités du core (assets,
jobs, user manager, websocket) sans import dur ; toutes les routes sont sous
`/mjr/am/*` ; les middlewares ne s'appliquent qu'aux chemins Majoor (y compris
le miroir `/api/mjr/am/*` ajouté par `server.py`).
- **Nœuds** : schéma V3 (`comfy_api.latest`, `comfy_entrypoint`) avec
dégradation à zéro nœud si une dépendance optionnelle manque.
## 2. Problèmes trouvés et corrigés dans cette branche
### 2.1 `graphToPrompt()` appelé de façon synchrone (frontend)
`serializeCurrentHostWorkflow()` (utilisé par le bouton d'en-tête du panneau)
appelait `app.graphToPrompt()` et lisait `.workflow` directement. Depuis le
frontend 1.x, `graphToPrompt` est **asynchrone** (`Promise<{output, workflow}>`,
confirmé dans `comfyui-frontend-types@1.60`). Conséquences : sérialisation
complète des widgets lancée pour rien à chaque appel, résultat ignoré, rejet de
promesse potentiellement non géré.
Correctif : on sérialise d'abord le **graphe racine** (`getHostRootGraph`), ce
qui évite aussi d'exporter seulement le sous-graphe ouvert ; `graphToPrompt` ne
reste qu'en repli pour les anciens hôtes synchrones, et une promesse retournée
est neutralisée proprement. Tests ajoutés dans
`ui/tests/host_adapter_contract.vitest.ts`.
### 2.2 État « modifié » du workflow
`isHostWorkflowDirty()` ne lisait que des drapeaux historiques. Il interroge
désormais d'abord `app.extensionManager.workflow.activeWorkflow.isModified`,
le signal officiel du store de workflows, puis retombe sur les anciens.
### 2.3 Types officiels du frontend absents
`@comfyorg/comfyui-frontend-types@1.57.3` a été publié **sans `index.d.ts`**
(seulement `package.json` + `LICENSE` ; idem 1.56.0). Avec `skipLibCheck`, le
type `OfficialComfyApp` réexporté par `ui/types/comfyui-frontend.d.ts`
devenait silencieusement inutilisable. Mise à jour vers `^1.60.0` (contient
bien les déclarations) ; `tsc` reste vert.
### 2.4 Clés de requête aiohttp
aiohttp ≥ 3.13 émet `NotAppKeyWarning` pour `request["mjr_user_id"]`,
`request["mjr_stats"]`, etc. Nouveau module `mjr_am_backend/request_keys.py` :
utilise `web.RequestKey` quand il existe, sinon la clé texte historique (ComfyUI
n'exige que `aiohttp>=3.11`). Tous les lecteurs/écrivains passent par ces
constantes, ce qui évite aussi toute collision avec les clés d'autres
extensions sur l'objet requête partagé.
### 2.5 Lint
Imports non triés dans 5 fichiers (`ruff --fix`) qui faisaient échouer
`npm run quality:py`.
## 3. Risques de conflit — état
| # | Risque | Statut |
| --- | --- | --- |
| R1 | `/api/v2/assets/*` occupait l'espace de noms du core (`/api/...`) et pouvait être masqué sans erreur. | **Corrigé** : surface déplacée vers `/mjr/am/compat/assets/*` ; l'ancien chemin n'est enregistré que si `MJR_AM_LEGACY_API_V2_ASSETS=1` (déprécié, avertissement au démarrage). |
| R2 | `api.queuePrompt` était enveloppé pour suivre les prompts ; l'ordre d'enveloppement avec d'autres extensions n'est pas garanti. | **Corrigé** : plus aucune interception. Sur `execution_start` (websocket), le prompt est lu via l'API Jobs du core (`api.getJobDetail` → `GET /api/jobs/{id}`, champ `workflow.prompt`), puis `/queue` et `/history/{id}` pour les anciens cores. `wrapHostQueuePrompt` a été retiré. |
| R3 | Middlewares insérés en tête de `app.middlewares` (filtrés sur les chemins Majoor ; échec rattrapé si l'app est figée). | Inchangé, sans risque : surveiller l'étape `security_middlewares` dans `/mjr/am/health`. |
| R4 | Doublon fonctionnel avec l'onglet « Assets » natif. | **Corrigé (opt-in)** : réglage `Majoor.Integration.HideNativeAssetsTab` (désactivé par défaut) qui masque l'onglet natif via `getSidebarTabs`/`unregisterSidebarTab` et le restaure à l'identique quand on le désactive. |
| R5 | `requires-python <3.14` bloquait Python 3.14. | **Corrigé** : `>=3.10, <3.15` ; suite complète validée sous 3.14 (1931 tests OK) ; 3.14 ajouté à la matrice CI et à `tox`. |
| R6 | `@comfyorg/litegraph` séparé alors que le frontend exporte ses types. | Ouvert (faible). |
| R7 | `import { app } from "../../scripts/app.js"` (shim encore supporté). | Ouvert (faible). |
| R8 | 913 erreurs de typage dans `tsconfig.test.json`. | **Corrigé** : 0 erreur ; ajouté à la CI (`python-tests.yml`). Un vrai bug de test trouvé au passage (clé `stubs` dupliquée dans `asset_card_inner.vitest.ts`). |
## 3 bis. Audit du système de notifications
| Constat | Avant | Après |
| --- | --- | --- |
| API utilisée | `extensionManager.toast.add()` uniquement, **dépréciée** par ComfyUI (`ToastManager` 1.60). | API typée `success/error/info/warning/loading(title, {description, duration, action, closable, id})` ; `add()` seulement en repli pour les anciens frontends. |
| Détection | `getExtensionToastApi` exigeait `toast.add` : le jour où ComfyUI le retire, **tous les toasts Majoor disparaissent en silence**. | Accepte l'API typée ou `add()`. Test dédié. |
| Opérations longues (restauration/réinitialisation de la base, backfill vectoriel) | Chaque étape (`started`, `running`, …) **empilait un nouveau toast**. | Un seul toast `loading`, mis à jour en place via le même `ToastId`, puis remplacé par le résultat final (succès ou erreur). |
| Toasts persistants | `life` omis. | Durée maximale sûre (2³¹−1 ms) au lieu de `Infinity`, qui serait ramené à 0 par `setTimeout` et fermerait le toast immédiatement. |
| Liens d'action (`actionUrl`) | Ignorés dans le toast. | Bouton d'action natif, limité aux liens `http(s)` et aux chemins relatifs du même site (`javascript:` refusé). |
| Contournements | `genInfoOverridePicker.ts` appelait `toast.add()` directement ; `comfyAlert` utilisait `addAlert`/`add`. | Tout passe par `comfyToast` ou l'API typée. |
| Code mort | Repli `app.ui.toast` (API inexistante dans le frontend actuel), `_dismissAllFallbackErrorToasts` vide, CSS `.mjr-toast*` des anciens toasts DOM. | Supprimés. |
| Démontage | Les toasts en cours restaient affichés. | `dismissTrackedToasts()` à l'arrêt de l'extension. |
| Centre de messages (cloche) | S'ouvrait tout seul lors d'une réinitialisation ou d'un backfill. | Conservé comme **historique consultable** (notes de version, journal, raccourcis), car ComfyUI n'offre pas de boîte de réception aux extensions ; il ne s'ouvre plus de lui-même. |
## 3 ter. Nouveaux assets, suppressions et scans (comparaison avec ComfyUI core)
Comment fait le core (`app/assets`, `comfy_execution/asset_enrichment.py`) :
- il enregistre les sorties **au moment où `executed` est émis**, y compris pour les exécutions servies depuis le cache, sans attendre de scan disque ;
- un fichier disparu est marqué `is_missing` et **récupéré** s'il revient avec le même hash ; un état illisible est « instable » et ne supprime rien ;
- les scans de fond sont mis en pause pendant une génération et signalés par les événements `assets.seed.*`.
| Constat Majoor | Gravité | Correctif |
| --- | --- | --- |
| Triggers `assets_fts` (FTS5 à contenu externe) écrits avec `UPDATE`/`DELETE` : **chaque renommage ou déplacement échouait** (« database disk image is malformed »). Le watcher retombait sur supprimer + réindexer, d'où un nouvel id et la **perte des notes, tags et collections**, plus une fausse « récupération de base corrompue ». | Critique | Triggers avec la commande FTS5 `'delete'`, mise à jour limitée à `filename, subfolder` ; réparation automatique des bases existantes au démarrage, avec reconstruction de l'index (`'rebuild'`). |
| `on_prompt_end` est appelé **avant** `PromptQueue.task_done()` (`main.py` du core) : l'ingestion post-génération pouvait lire un historique vide et n'indexer rien. Le frontend ne couvre que le client qui a lancé le prompt (`executed` n'est envoyé qu'à lui). | Haute | Attente de l'entrée d'historique (backoff 0,05 à 3,2 s, environ 6 s au maximum). |
| Prune de scan : une erreur de `stat` (partage réseau, permission) comptait comme « fichier supprimé », et les données utilisateur étaient effacées. | Haute | Existence à trois états (présent / absent / inconnu) ; racine illisible = aucun prune. |
| Suppressions et déplacements détectés par le watcher ou le prune : aucun événement, cartes périmées dans la grille. | Moyenne | `mjr-assets-removed` (ids) et `mjr-asset-updated` (nouveau chemin) ; le frontend réutilise le retrait en place des suppressions faites depuis l'interface. |
| Scan de fond terminé : aucun rafraîchissement de grille ni notification ; compteur `pruned` gonflé par les cascades. | Moyenne | `mjr-scan-complete` porte `origin` et `needs_refresh` ; un seul rechargement si nécessaire ; un toast natif « N nouveaux, M retirés » pour un scan de dossier ; silence pour les indexations par génération, déjà couvertes par les cartes en direct et le badge. |
Vérifié sans changement :
- le badge de la barre latérale et le flux de génération dédupliquent chaque asset ;
- l'indexation vectorielle automatique est dédupliquée ;
- le rechargement après `mjr-core-execution-assets-ready` sert à finaliser les piles d'exécution et il est différé quand la grille est cachée.
Pistes restantes :
- marquage « manquant » réversible façon core, plutôt qu'une suppression définitive, pour récupérer un fichier qui revient ; cela demande une migration de schéma ;
- retirer aussi du flux de génération les éléments supprimés hors de l'interface ;
- un seul événement par nouvel asset au lieu de `mjr-asset-added` plus `mjr.asset.indexed` (chacun doublé par l'enveloppe `mjr.event`).
## 3 quater. Piles de doublons : gel de ComfyUI et piles « corrompues »
**Symptôme.** Un clic sur le badge de doublons (icône de copie suivie d'un nombre) gèle tout l'onglet ComfyUI ; certaines piles empilent des images qui n'ont rien à voir entre elles.
**Cause du gel : une boucle de rendu Vue infinie.**
1. Le template de `VirtualAssetGridHost.vue` lit `asset._mjrDupStack` et `asset._mjrDupCount`, et passe `:ref="getCardRef(asset)"`, qui crée une nouvelle fonction à chaque rendu. Vue rappelle donc les refs à chaque rendu (`null`, puis l'élément).
2. `bindCardRef` appelait `syncRenderedDuplicateCards`, qui remettait `_mjrDupStack` à `false` sur tous les membres puis à `true` sur une carte principale, et assignait un nouveau tableau `_mjrDupMembers`.
3. En parallèle, le `computed` `displayAssets` faisait l'inverse, avec une autre carte principale (`bucket[0]` contre `selectStackRepresentative`).
4. Chaque écriture relançait le rendu du parent, donc les refs, donc de nouvelles écritures. En production, Vue n'a pas de limite de récursion : l'onglet tourne à l'infini.
La vue « doublons » déclenchait la boucle à coup sûr : elle affiche N cartes de même nom, aussitôt re-regroupées. Un test le reproduit : **31 rendus en 5 passages** avec l'ancien code, contre zéro rendu supplémentaire après correctif.
**Cause de la « corruption ».** Quatre définitions du doublon coexistaient :
- `AssetCardRenderer` et `displayAssets` : même nom dans le même source / racine / sous-dossier ;
- `useVirtualGrid.dedupeAssetsByKey` et `syncRenderedDuplicateCards` : **nom seul, tous dossiers confondus**.
Ces deux derniers retiraient de `state.assets` des fichiers distincts d'autres dossiers et les rangeaient sous une mauvaise carte. Celle-ci recevait au passage le temps de génération d'une autre image (`preserveRepresentativeGenerationTime`).
**Correctif**
- `buildDisplayAssets` est la seule source de vérité. Ses écritures sont gardées (assignation seulement si la valeur brute change, tableau des membres conservé tant que la composition ne change pas), avec une option `collapseDuplicates`.
- `syncRenderedDuplicateCards` ne touche plus l'état réactif.
- Une seule fonction de ref par carte (`getCardRef`).
- `dedupeAssetsByKey` ne retire plus que les vrais doublons (même id ou même clé).
- `AssetCardRenderer.appendAssets` ne retire plus de copies : il ne fait que compter.
- La vue « doublons » (scope `similar`) liste toutes les copies, avec des membres copiés sans cycle (`toDuplicateGroupMembers`).
**À suivre.** Une pile « même dossier, même nom » correspond à plusieurs lignes en base pour un même fichier. C'est possible avec des index créés par d'anciennes versions, par exemple des variantes de casse sous Windows : les insertions actuelles normalisent le chemin. Une fusion de ces lignes, qui conserverait notes, tags et collections, reste à faire comme opération de maintenance explicite.
## 3 quinquies. Recherche, fichiers JSON, sidecars et viewers
### Recherche (FTS5)
Testée sur un vrai index avec des noms piégeux.
| Requête | Avant | Après |
| --- | --- | --- |
| `at-7` (frappe en cours), `(2)`, `AND`, `NEAR`, `NOT x`, `x` | erreur « Invalid search query syntax » | résultats |
| `"` seul (début d'une phrase) | erreur | tout afficher |
| `портрет`, `猫の写真` | caractères supprimés, donc erreur | recherche dans l'écriture d'origine |
Cause : `_sanitize_fts_query` retirait les mots-outils, les jetons d'un caractère et les opérateurs, puis tous les caractères non ASCII. Quand il ne restait rien, SQLite recevait un `MATCH ''` invalide.
Correctif :
- jetons `\w+` dans toutes les écritures ;
- repli sur les termes bruts quand le filtrage vide tout ;
- requête sans caractère de mot traitée comme « tout afficher » ;
- retrait des accents limité au latin, comme `unicode61`.
Les mots réservés en minuscules sont des mots ordinaires pour FTS5, ses opérateurs n'existant qu'en majuscules.
### Sidecars JSON
- Avant : uniquement pour la 3D (`<fichier>.json`).
- Maintenant : pour toutes les images, vidéos et fichiers audio (module `features/metadata/sidecar.py`). On cherche `<fichier>.<ext>.json`, puis `<nom>.json`. Le fichier peut contenir un workflow UI, un prompt API ou un wrapper `{workflow, prompt}` (valeurs éventuellement sous forme de chaîne JSON), plus `parameters`, `geninfo` et `generation_time_ms`. Taille maximale 10 Mo, BOM accepté.
- Les métadonnées intégrées au fichier restent prioritaires.
- Un JSON sans rapport (des réglages, par exemple) n'est pas pris pour un sidecar.
Limite connue : un sidecar ajouté **après** l'indexation n'est pris en compte qu'au prochain scan forcé, car la date de modification du média ne change pas.
### Reconnaissance des JSON de workflows (onglet Workflow)
Vérifié sur un vrai dossier :
- les instantanés `.history/<nom>/<date>.json` apparaissaient comme workflows ;
- un `settings.json` quelconque apparaissait comme workflow ;
- un workflow avec BOM disparaissait ;
- un prompt API s'affichait avec 0 nœud puis s'ouvrait sur un canvas vide.
Correctif :
- parcours qui ignore les dossiers et fichiers cachés ;
- `workflow_json_format()` (`ui`, `api` ou rien), qui accepte aussi un workflow vide et les wrappers ;
- lecture en `utf-8-sig` ;
- les JSON invalides sortent de l'index ;
- ouverture côté frontend par `app.loadApiJson` pour le format API ;
- `loadGraphData`, devenu asynchrone, n'a plus de rejet non géré.
### Viewers
- **Viewer principal** : les médias sont correctement libérés (`destroyMediaProcessorsIn` : pause, retrait de `src`, `load()`), et les écouteurs globaux sont retirés.
- **Viewer flottant** : il ne faisait que mettre en pause avant `replaceChildren()` et `dispose()`. Une `<video>` détachée garde son lecteur, et Chromium en limite le nombre (environ 75). En mode Live, qui se réaffiche à chaque génération, les lecteurs s'accumulaient. Ils sont maintenant libérés (`releaseFloatingViewerMediaIn`) ; `hide()` continue seulement de mettre en pause, pour pouvoir réafficher.
- **À décider** : quand il est visible, le viewer flottant intercepte en phase de capture les touches simples `v`, `k`, `l` et `c` sur toute la page, hors champs de saisie. Aucun raccourci ComfyUI par défaut n'est touché, mais un raccourci personnalisé sur ces touches serait masqué. On pourrait limiter l'interception aux moments où le focus est dans le viewer.
## 3 sexies. Correctifs de suivi
| Point ouvert | Correctif |
| --- | --- |
| Un sidecar ajouté **après** l'indexation était ignoré. Le cache de métadonnées est indexé par l'état du média seul, et une réindexation non incrémentale sautait les assets ayant déjà des métadonnées « riches ». | Le watcher traite les `.json` (`sidecar_media_paths`) : il invalide le cache et le journal du média (`invalidate_cached_metadata`), puis le réindexe en mode non incrémental (`build_sidecar_reindex_callback`). Le raccourci « métadonnées riches » ne s'applique plus qu'en mode incrémental. |
| Lignes en double pour un même fichier (casse ou séparateur différents sous Windows, héritage d'anciennes versions), affichées en fausses piles de doublons. | `adapters/db/schema_dedupe.py`, au démarrage, sous Windows seulement : fusion vers l'orthographe canonique, avec note maximale, union des tags (via `INSERT` pour mettre à jour la recherche), couverture de pile, identifiants d'exécution et embedding. Ne fait rien si l'index est propre. |
| Le flux de génération gardait les éléments supprimés. | `removeGeneratedAssets()`, appelé sur `ASSETS_DELETED` (suppression depuis l'interface ou sur le disque). |
| Les raccourcis `v`, `k`, `l` et `c` du viewer flottant étaient interceptés sur toute la page. | Actifs seulement si la souris ou le focus sont dans le viewer, ou depuis sa fenêtre pop-out. |
| R6 : paquet `@comfyorg/litegraph` séparé. | Types repris de `@comfyorg/comfyui-frontend-types`, paquet retiré. |
| 5 fichiers frontend morts (signalés par knip). | Supprimés. |
| Test dépendant de l'ordre (`test_comfy_core_adapter_reads_input_output_directories`). | La fixture laissait fuir `MJR_AM_INPUT_DIRECTORY` (restaurée par `monkeypatch.delenv` en fin de test) ; corrigé. |
Reste volontairement inchangé : l'import `../../scripts/app.js` (R7), un shim toujours supporté par ComfyUI.
## 4. Feuille de route d'évolution (meilleure intégration)
1. **Progression par nœud** — écouter l'événement websocket `progress_state`
(déclaré dans `comfyui-frontend-types`) pour une progression fiable dans les
sous-graphes et en exécution parallèle, en complément de `progress`.
2. **API Jobs du core** — déjà utilisée pour la progression (R2) ; étendre à
`api.getJobAssets` pour rattacher chaque sortie à son prompt.
3. **API Assets du core** — quand `capabilities.assets` est vrai, synchroniser
les tags/notes avec `/api/assets` (lecture seule d'abord), afin que les
assets soient visibles dans les deux panneaux sans double indexation.
4. **Multi-utilisateur** — brancher `onAuthUserResolved` (nouveau hook
d'extension) pour rafraîchir les réglages par utilisateur au lieu de sonder.
5. **Barre d'actions** — `actionBarButtons` / `topbarBadges` remplacent proprement
le bouton MFV injecté dans la barre supérieure (`teardownTopBarMfvButton`).
6. **Mode Nodes 2.0 (Vue)** — vérifier que le flux de nœuds (`nodeStream`) et
les menus contextuels fonctionnent quand le canvas LiteGraph n'appelle plus
`onDrawForeground` ; privilégier les données d'exécution aux hooks de dessin.
## 5. Règles pour rester sans conflit
- Ne jamais enregistrer de route hors de `/mjr/am/*` (ni sous `/api/*`).
- Toute nouvelle API ComfyUI passe par `hostAdapter.ts` (front) ou
`adapters/comfy_core.py` (back), avec détection de capacité et repli.
- Préfixer réglages (`Majoor.*`), commandes (`mjr.*`), événements websocket
(`mjr-*` / `mjr.*`) et clés de requête (`request_keys.py`).
- Ne pas modifier de prototypes LiteGraph ni d'objets globaux du core ; un
enveloppement inévitable doit être réversible (cf. `wrapHostQueuePrompt`).
- Reconstruire `dist/` (`npm run build`) à chaque modification de `ui/`.
+34 -50
View File
@@ -13,7 +13,7 @@ This guide covers all active keyboard shortcuts for the Majoor Assets Manager, i
- [Global / Panel Hotkeys](#global--panel-hotkeys)
- [Grid View Hotkeys](#grid-view-hotkeys)
- [Standard Viewer Hotkeys](#standard-viewer-hotkeys)
- [Viewer Hotkeys](#viewer-hotkeys)
- [Majoor Floating Viewer (MFV) Hotkeys](#majoor-floating-viewer-mfv-hotkeys)
- [Video Playback Hotkeys](#video-playback-hotkeys)
- [Drag & Drop Shortcuts](#drag--drop-shortcuts)
@@ -90,63 +90,47 @@ These shortcuts apply when the asset grid has focus.
---
## Standard Viewer Hotkeys
## Viewer Hotkeys
These shortcuts apply when the **Standard Viewer** overlay is open (double-click on asset).
These shortcuts apply when the **Studio viewer** is open (double-click on an asset, or `Enter`).
### General
| Shortcut | Action |
| --------- | ----------------------------------- |
| **Esc** | Close Viewer |
| **F** | Toggle Fullscreen |
| **D** | Toggle Info Panel (Generation Data) |
| **Space** | Play/Pause Video |
| **S** | Download Original from context menu |
| Shortcut | Action |
| -------- | ------ |
| **Esc** | Close the open tool panel, then the viewer |
| **F** | Toggle fullscreen |
| **Tab** | Show / hide the inspector (generation data) |
| **0**–**5** | Set rating |
| **Left / Right Arrow** | Previous / next asset (**Home** / **End**: first / last) |
| **Mouse Wheel** / **+** / **-** | Zoom in / out (**Alt+0**: fit) |
| **Click+Drag** | Pan when zoomed in |
| **Ctrl+C** | Copy the image or current video frame |
### Navigation
### Video / audio
| Shortcut | Action | Notes |
| --------------- | --------------- | ----------------------------------------- |
| **Left Arrow** | Previous Asset | Default behavior |
| **Right Arrow** | Next Asset | Default behavior |
| **Left Arrow** | Step Frame (-1) | **Only when Video Player bar is focused** |
| **Right Arrow** | Step Frame (+1) | **Only when Video Player bar is focused** |
| **Mouse Wheel** | Zoom In/Out | |
| **Click+Drag** | Pan Image | When zoomed in |
| Shortcut | Action |
| -------- | ------ |
| **Space** | Play / pause |
| **,** / **.** | Previous / next frame |
| **Shift+Left / Right** | Seek -5 s / +5 s |
| **I** / **O** / **U** | Set loop A / set loop B / clear loop |
| **[** / **]** | Slower / faster |
| **M** | Mute |
### Tools & Analysis
### Review tools (images and videos)
| Shortcut | Action | Notes |
| ------------- | ------------------------------------------------- | ----------------- |
| **I** | Set In Point (video) / Toggle Pixel Probe (image) | Context-sensitive |
| **O** | Set Out Point | Video only |
| **C** | Copy Probed Color (Hex) | |
| **L** | Toggle Loupe | |
| **Z** | Toggle Zebra (Exposure) | |
| **G** | Cycle Grid Overlays | |
| **Alt+1** | Toggle 1:1 Pixel View | |
| **+** / **-** | Zoom In / Out | |
### Enhancement Tools
| Shortcut | Action |
| --------------------- | ---------------------------- |
| **E** | Toggle Exposure (EV) Control |
| **M** | Toggle Gamma Correction |
| **1** / **2** / **3** | Isolate R/G/B Channel |
| **0** | Reset to RGB (all channels) |
| **A** | Toggle Alpha Channel View |
| **Y** | Toggle Luma (Y) View |
### Analysis Overlays
| Shortcut | Action |
| ----------- | ------------------ |
| **Shift+Z** | Toggle False Color |
| **Shift+H** | Toggle Histogram |
| **Shift+W** | Toggle Waveform |
| **Shift+V** | Toggle Vectorscope |
| Shortcut | Action |
| -------- | ------ |
| **Z** | Toggle zebra (exposure check) |
| **S** | Cycle scopes (off, histogram, waveform, both) |
| **G** | Cycle guides (thirds, golden, centre, safe areas) |
| **P** | Toggle pixel probe |
| **L** | Toggle loupe |
| **C** | Toggle A/B compare |
| **B** | Use the current asset as B |
| **X** | Swap A and B |
| **R** | Reset all review tools |
---
+1 -1
View File
@@ -12,7 +12,7 @@ This guide provides detailed instructions for installing and configuring the Maj
### System Requirements
- ComfyUI installation (≥ 0.13.0 recommended)
- Python 3.10, 3.11, or 3.12 (3.13 compatible)
- Python 3.10 to 3.14
- At least 500MB free disk space for the extension and dependencies
- Administrator privileges (for installation and optional tool installations)
+1
View File
@@ -47,6 +47,7 @@ Use this when you want the cleanest single-asset view.
- one asset visible at a time
- best for inspection and overlay controls
- good default when you are reviewing video or a single final image
- video and audio use the **Studio player** (same as the main viewer): scrubber with buffered range and hover time + frame, frame counter, frame-by-frame stepping (`←` / `→` while the viewer has focus, `Space` to play / pause), A-B loop, loop, speed, volume, fullscreen and *save this frame as PNG*. Secondary controls fold away when the window is narrow. Mute and speed carry over to the next asset. Compare modes keep their synchronised controls.
### A/B compare
+8
View File
@@ -460,6 +460,14 @@ Environment variables provide secure configuration without code changes:
- Options: `1`, `true`, `yes`, `on` to disable
- Example: `MJR_AM_NO_AUTO_PIP=1`
### API Compatibility
- **MJR_AM_LEGACY_API_V2_ASSETS**
- Purpose: Also serve the deprecated `/api/v2/assets/*` alias of `/mjr/am/compat/assets/*`
- Default: Disabled (the `/api` namespace belongs to ComfyUI core)
- Security Impact: The alias sits behind the same auth/CSRF middlewares as `/mjr/am/*`
- Options: `1`, `true`, `yes`, `on` to enable
- Example: `MJR_AM_LEGACY_API_V2_ASSETS=1`
## Security Testing
### Self-Assessment Checklist
+57 -360
View File
@@ -7,7 +7,58 @@
For a dedicated Majoor Floating Viewer walkthrough with focused screenshots, see [MFV_GUIDE.md](MFV_GUIDE.md).
The Majoor Assets Manager provides **two viewer experiences**:
The Majoor Assets Manager provides **two viewers**: the **Studio viewer** (main viewer) and the **Majoor Floating Viewer**.
### 0. Studio Viewer (main viewer, default)
Opening an asset (double-click, Enter, context menu) now shows the **Studio viewer**: a clean, review-first view for generated media.
- **Header**: previous / next with `n / N`, file name, resolution · format · duration · size, and actions (zoom, fit, 1:1, transparency checkerboard, compare, download, info panel, fullscreen, close).
- **Image stage**: wheel zooms around the cursor, drag to pan, double-click toggles fit ↔ 100 %, checkerboard to inspect alpha. Above 200 % pixels stay sharp (nearest-neighbour).
- **Player** (video / audio): big play button, scrubber with buffered range and hover time + frame, frame counter `f n / N · fps`, frame-by-frame stepping, A-B loop, loop, speed 0.25×–2×, volume, fullscreen, **save current frame as PNG**. Controls auto-hide while playing. Audio files show a live spectrum. Media is released when you move on.
- **3D models** (GLB, glTF, OBJ, STL, PLY, splats…): orbit with left drag, pan with right drag, zoom with the wheel.
- **Inspector** (right, `Tab`): rating and tags on top, then tabs **Generation** (prompts, models, LoRAs, sampler, seed… — same data as the asset sidebar), **Workflow** (graph preview, load into ComfyUI) and **File**.
- **Filmstrip**: thumbnails of the current list to jump anywhere.
#### Review tools (HUD rail)
Images and videos get a tool rail on the left of the stage. Each icon toggles a tool; tools with settings open a small panel next to the rail (`Esc` closes it). Colour tools run on the GPU, so they also work on playing video.
| Tool | What it does |
| --- | --- |
| **Channels** | RGB, R, G, B, alpha or luma as greyscale |
| **Exposure / gamma** | ±5 EV and display gamma 0.1–3 (double-click a slider to reset) |
| **Zebra** (`Z`) | Stripes over highlights above the threshold (50–100 %) |
| **Scopes** (`S`) | RGB + luma histogram, luma waveform, or both — live on video |
| **Guides** (`G`) | Rule of thirds, golden ratio, centre cross, action / title safe areas |
| **Aspect mask** | Darkens outside 16:9, 9:16, 1:1, 4:3 or 2.39 (adjustable opacity) |
| **Pixel probe** (`P`) | Hex, RGB, luma and pixel coordinates under the cursor |
| **Loupe** (`L`) | Pixel-accurate magnifier; right-click the icon for zoom 2–32× and size |
| **Compare A/B** (`C`) | Wipe ↔, wipe ↕, side by side, difference, add, subtract (images), multiply, screen |
| **Copy** (`Ctrl+C`) | Copies the image or the current video frame to the clipboard as PNG |
| **Reset** (`R`) | Resets every tool |
**Compare**: B is the next asset of the same kind in the list. In the compare panel, `‹ ›` picks another B, **Set B** (`B`) pins the current asset as B and moves on, **Swap** (`X`) exchanges A and B. Drag the wipe line (double-click it to centre it). Videos are kept in sync (play, pause, seek, speed, loops). Raise the exposure in *difference* mode to make subtle changes visible. Opening an A/B or side-by-side compare from the grid or the floating viewer lands here with that mode already on.
| Key | Action |
| --- | --- |
| `←` / `→` | Previous / next asset (`Home` / `End`: first / last) |
| `Space` | Play / pause |
| `,` / `.` | Previous / next frame |
| `Shift+←` / `Shift+→` | Seek −5 s / +5 s |
| `I` / `O` / `U` | Set loop A / set loop B / clear loop |
| `[` / `]` | Slower / faster |
| `M` | Mute |
| `0`–`5` | Set rating |
| `+` / `-`, `Alt+0` | Zoom in / out, fit |
| `C` / `B` / `X` | Compare on/off / set current as B / swap A and B |
| `Z` / `S` / `G` | Zebra / cycle scopes / cycle guides |
| `P` / `L` | Pixel probe / loupe |
| `R` | Reset review tools |
| `Ctrl+C` | Copy image or frame |
| `Tab` | Show / hide the inspector |
| `F` | Fullscreen |
| `Esc` | Close the open tool panel, then the viewer |
### 1. Majoor Floating Viewer (MFV) — NEW! 🎉
@@ -25,17 +76,6 @@ A lightweight, draggable floating panel for **real-time generation comparison**:
**Best for**: Real-time workflow monitoring, quick comparisons, node preview, parameter tweaking
### 2. Standard Viewer
A full-featured overlay viewer with **advanced analysis tools**:
- **Enhancement Tools**: Exposure, gamma, channel isolation
- **Analysis Tools**: False color, zebra, histogram, waveform, vectorscope
- **Visual Overlays**: Grid, pixel probe, loupe
- **Video Controls**: Timeline, in/out points, speed control
**Best for**: Detailed analysis, quality inspection, metadata review
---
## Table of Contents
@@ -52,20 +92,7 @@ A full-featured overlay viewer with **advanced analysis tools**:
- [MFV Controls](#mfv-controls)
- [MFV Keyboard Shortcuts](#mfv-keyboard-shortcuts)
### Standard Viewer
- [Opening the Standard Viewer](#opening-the-standard-viewer)
- [Viewer Layout](#viewer-layout)
- [Viewer Navigation](#viewer-navigation)
- [Image Enhancement Tools](#image-enhancement-tools)
- [Analysis Tools](#analysis-tools)
- [Visual Overlays](#visual-overlays)
- [Professional Analysis Tools (Scopes)](#professional-analysis-tools-scopes)
- [Video-Specific Features](#video-specific-features)
- [Export Capabilities](#export-capabilities)
- [Standard Viewer Hotkeys](#standard-viewer-hotkeys)
### Both Viewers
### Both viewers
- [Troubleshooting](#troubleshooting-viewer-issues)
- [Best Practices](#best-practices)
@@ -348,336 +375,6 @@ See [HOTKEYS_SHORTCUTS.md](HOTKEYS_SHORTCUTS.md) for complete shortcut list.
---
## Standard Viewer
## Viewer Navigation
### Basic Navigation
- **Zoom**: Use mouse wheel to zoom in/out
- **Pan**: Click and drag to move around the image
- **Fit to Screen**: Automatically scales image to fit window
- **Actual Size**: View image at 1:1 pixel ratio (Alt+1)
### Asset Navigation
- **Next/Previous**: Navigate between assets in the current set
- **Keyboard Arrows**: Use arrow keys to move between assets
- **Direct Selection**: Click on thumbnails if available
## Image Enhancement Tools
### Exposure Adjustment (EV)
The exposure adjustment tool allows you to modify the brightness of your image:
1. **Accessing the Tool**:
- Locate the exposure control in the toolbar
- Usually represented by a sun or EV icon
2. **Adjusting Exposure**:
- Adjust exposure compensation from -5 to +5 EV
- Real-time preview shows changes immediately
- Use to evaluate details in shadows/highlights
- Preserves color relationships while adjusting brightness
3. **Use Cases**:
- Revealing details in underexposed areas
- Recovering highlights in overexposed regions
- Comparing exposure differences between assets
- Evaluating dynamic range
### Gamma Correction
Gamma correction adjusts the midtone brightness:
1. **Accessing the Tool**:
- Find the gamma control in the toolbar
- Usually labeled with γ or gamma symbol
2. **Adjusting Gamma**:
- Adjust gamma curve from 0.25 to 4.0
- Alters midtone brightness without affecting highlights/shadows as much
- Real-time application with live preview
- Useful for fine-tuning contrast in specific tonal ranges
3. **Use Cases**:
- Enhancing midtone contrast
- Adjusting overall tonal balance
- Fine-tuning for display characteristics
- Preparing for different output conditions
### Channel Isolation
The viewer allows you to isolate different color channels:
1. **Available Channels**:
- **RGB**: View all color channels combined
- **R**: View only red channel information
- **G**: View only green channel information
- **B**: View only blue channel information
- **Alpha**: View transparency/alpha channel
- **Luma**: View luminance information only
2. **Using Channel Isolation**:
- Select the desired channel from the toolbar
- Observe the isolated channel information
- Compare between different channels
- Identify channel-specific issues
3. **Use Cases**:
- Identifying color channel imbalances
- Detecting channel-specific noise
- Analyzing alpha channel transparency
- Evaluating luminance distribution
## Analysis Tools
### False Color Mode
False color mode helps identify exposure issues:
1. **Activating False Color**:
- Enable false color mode from the toolbar
- Usually represented by a color palette icon
2. **Interpreting Results**:
- Identifies overexposed and underexposed areas
- Highlights clipped highlights (typically white/magenta)
- Shows blocked shadows (typically blue/black)
- Helps evaluate dynamic range and exposure
3. **Use Cases**:
- Checking for highlight clipping
- Identifying shadow detail loss
- Evaluating overall exposure balance
- Comparing exposure between different assets
### Zebra Patterns (Z)
Zebra patterns indicate areas approaching clipping:
1. **Enabling Zebra**:
- Activate zebra patterns from the toolbar
- Usually labeled with "Z" or zebra icon
2. **Understanding Zebra**:
- Displays 100% IRE (95% for Rec.2020) areas as zebra stripes
- Helps identify areas approaching clipping
- Adjustable threshold levels
- Useful for maintaining highlight detail
3. **Use Cases**:
- Protecting highlight detail
- Setting optimal exposure levels
- Comparing exposure between shots
- Quality control for consistent exposure
## Visual Overlays
### Grid Overlays (G)
Grid overlays assist with composition:
1. **Available Grid Types**:
- **Off**: No grid overlay
- **Thirds**: Rule of thirds grid lines
- **Center**: Center crosshair
- **Safe Area**: TV broadcast safe area guides
- **Golden Ratio**: Golden ratio composition guides
2. **Using Grid Overlays**:
- Cycle through grid types using "G" key
- Assess compositional balance
- Align elements to grid lines
- Compare compositions between assets
3. **Use Cases**:
- Composition analysis
- Alignment verification
- Rule of thirds evaluation
- Broadcast compliance checking
### Pixel Probe (I)
Pixel probe provides detailed pixel information:
1. **Activating Pixel Probe**:
- Enable pixel probe from toolbar or press "I"
- Hover over image to see information
2. **Information Provided**:
- Exact pixel coordinates
- RGB/RGBA values at cursor position
- Hex color code
- Luminance value information
3. **Use Cases**:
- Color accuracy verification
- Detail inspection
- Color sampling
- Quality assessment at pixel level
### Loupe Magnification (L)
Loupe provides magnified view of specific areas:
1. **Using Loupe**:
- Activate loupe from toolbar or press "L"
- Hover over area of interest
- View magnified representation
2. **Features**:
- Adjustable magnification level
- Shows fine detail at pixel level
- Helpful for quality assessment
- Real-time magnification
3. **Use Cases**:
- Quality inspection
- Artifact detection
- Detail analysis
- Sharpness evaluation
## Professional Analysis Tools (Scopes)
### Histogram
The histogram shows tonal distribution:
1. **Accessing Histogram**:
- Enable from scopes section
- Shows RGB channel distribution
2. **Interpretation**:
- X-axis represents tonal range (shadows to highlights)
- Y-axis represents pixel count
- Separate curves for R, G, B channels
- Identifies tonal distribution patterns
3. **Use Cases**:
- Exposure evaluation
- Contrast analysis
- Color balance assessment
- Dynamic range evaluation
### Waveform
The waveform shows luminance distribution:
1. **Accessing Waveform**:
- Enable from scopes section
- Shows luminance across image
2. **Interpretation**:
- Horizontal axis represents image width
- Vertical axis represents luminance levels
- Brighter areas appear higher in the display
- Darker areas appear lower in the display
3. **Use Cases**:
- Exposure evaluation
- Luminance distribution analysis
- Highlight/shadow placement
- Consistency checking
### Vectorscope
The vectorscope shows color information:
1. **Accessing Vectorscope**:
- Enable from scopes section
- Shows color information in chromaticity diagram
2. **Interpretation**:
- Shows color saturation and hue
- Identifies color distribution
- Reveals color casts or imbalances
- Shows skin tone placement
3. **Use Cases**:
- Color balance correction
- Skin tone evaluation
- Saturation assessment
- Color cast identification
## Video-Specific Features
### Video Playback Controls
- **Play/Pause**: Standard play/pause functionality
- **Loop/Once**: Toggle between continuous and single playback
- **Speed Control**: Adjustable playback speed (0.25x to 4x)
- **Frame Stepping**: Precise frame-by-frame navigation (Left/Right Arrow keys when player bar is focused)
### Timeline Features
- **Seek Bar**: Drag to jump to specific points in video
- **Current Time**: Display of current playback position
- **Duration**: Total video duration display
- **Frame Counter**: Current frame number display
### In/Out Points
- **Set In Point**: Mark start of desired segment (I key)
- **Set Out Point**: Mark end of desired segment (O key)
- **Segment Playback**: Play only the marked segment
- **Clear Points**: Remove In/Out markers
### Hardware Acceleration (WebGL)
The viewer automatically detects WebGL support to enable GPU-accelerated video rendering:
- **Benefits**: Real-time Exposure, Gamma, and Zebra analysis on 4K videos without CPU load
- **Fallback**: If WebGL is unavailable, it automatically switches to a robust CPU-based renderer (Canvas 2D)
## Export Capabilities
### Save Current Frame
- Capture current video frame as PNG
- Preserves full resolution and quality
- Saves to default output directory
- Includes generation metadata if available
### Copy to Clipboard
- Copy current view to system clipboard
- Best-effort format selection
- May not work on all platforms
- Useful for quick sharing
### Download Original
- Download the original asset file
- Maintains original format and quality
- Preserves embedded metadata
- Safe file transfer protocol
## Viewer Hotkeys
All viewer hotkeys are captured and don't affect ComfyUI or browser:
- **Esc**: Close viewer
- **0-5**: Set rating (single view)
- **Left/Right Arrow**: Step video frames (when player bar is focused)
- **F**: Toggle fullscreen
- **Z**: Toggle zebra patterns
- **G**: Cycle through grid overlays
- **I**: Toggle pixel probe
- **L**: Toggle loupe magnification
- **C**: Copy last probed color hex value
- **Alt+1**: Toggle 1:1 zoom
## Comparison Integration
The viewer seamlessly integrates with the comparison feature:
- Switch between single view and comparison modes
- Maintain analysis tools during comparison
- Apply enhancements to both compared assets
- Use analysis tools on comparison results
## Troubleshooting Viewer Issues
### Performance Problems
@@ -689,7 +386,7 @@ The viewer seamlessly integrates with the comparison feature:
### Tool Malfunctions
- **Missing Tools**: Verify WebGL support in your browser
- **Missing Tools**: The review tools apply to images and videos only (3D models and audio have their own controls)
- **Unresponsive Controls**: Refresh the viewer or browser
- **Display Issues**: Check browser zoom level and display settings
- **Audio Problems**: Check system audio settings for video files
@@ -708,14 +405,14 @@ The viewer seamlessly integrates with the comparison feature:
- Use keyboard shortcuts for faster navigation
- Apply analysis tools systematically
- Take advantage of export features for sharing
- Use comparison modes for parameter evaluation
- Use A/B compare (`C`) to evaluate parameter changes
### Quality Assessment
- Use pixel probe for detailed inspection
- Apply false color to check exposure
- Use the exposure and channel tools to check tones
- Use zebra patterns to protect highlights
- Employ scopes for professional analysis
- Use the histogram and waveform scopes for objective checks
### Workflow Integration
+11
View File
@@ -533,6 +533,17 @@ def get_prompt_output_files(prompt_id: str) -> list[PromptOutputFile]:
return _ADAPTER.output_files_from_history(prompt_id)
def has_prompt_history_entry(prompt_id: str) -> bool:
"""True once ComfyUI has stored the history entry for ``prompt_id``."""
safe_prompt_id = str(prompt_id or "").strip()
if not safe_prompt_id:
return False
try:
return isinstance(_ADAPTER.get_prompt_history(safe_prompt_id).get(safe_prompt_id), dict)
except Exception:
return False
def get_workflow_id_for_prompt(prompt_id: str) -> str | None:
"""Best-effort extraction of the workflow id associated with a prompt.
+9
View File
@@ -259,6 +259,15 @@ async def _ensure_schema(db) -> Result[bool]:
if not result.ok:
return result
# Windows: collapse rows that are one file under different path casing
# (shown as bogus duplicate stacks, with ratings/tags split). Best-effort.
try:
from .schema_dedupe import merge_duplicate_asset_rows
await merge_duplicate_asset_rows(db)
except Exception:
logger.debug("merge_duplicate_asset_rows skipped", exc_info=True)
version_result = await db.aset_schema_version(CURRENT_SCHEMA_VERSION)
if not version_result.ok:
logger.error("Failed to set schema version: %s", version_result.error)
+167
View File
@@ -0,0 +1,167 @@
"""Merge index rows that point at the same file through different path spellings.
On Windows, paths are case-insensitive and accept both separators, but the
``assets.filepath`` UNIQUE constraint compares exact strings. Indexes built by
older versions (before every insert went through ``normalize_filepath_str``)
can therefore hold several rows for one file, e.g. ``C:\\Out\\a.png`` and
``c:\\out\\a.png``. The grid shows them as a duplicate stack and the user's
rating/tags are split between the copies.
Each group is collapsed onto one row (the canonical spelling when present),
keeping the user's data: the highest rating, the union of tags, the stack
cover, execution/stack ids and the vector embedding. Collections reference
assets by normalized file path, so they are unaffected. Runs at startup and is
a no-op once the index is clean.
"""
from __future__ import annotations
import os
from typing import Any
from ...shared import Result, get_logger
logger = get_logger(__name__)
_COPY_IF_EMPTY_COLUMNS = ("job_id", "stack_id", "workflow_id", "source_node_id")
_SPECIFIC_ASSET_ID_TABLES = {"asset_metadata", "asset_tags"}
def _group_key(filepath: str) -> str:
return str(filepath or "").replace("\\", "/").lower()
def _canonical(filepath: str) -> str:
try:
return os.path.normcase(os.path.normpath(filepath))
except Exception:
return filepath
async def _rows(db: Any, sql: str, params: tuple = ()) -> list[dict[str, Any]]:
res = await db.aquery(sql, params)
return list(res.data or []) if res.ok else []
async def _existing_columns(db: Any, table: str) -> set[str]:
return {str(r.get("name")) for r in await _rows(db, f"PRAGMA table_info('{table}')")}
async def _other_asset_id_tables(db: Any) -> list[str]:
tables = await _rows(db, "SELECT name FROM sqlite_master WHERE type='table'")
out: list[str] = []
for row in tables:
name = str(row.get("name") or "")
if not name or name in _SPECIFIC_ASSET_ID_TABLES or name == "assets" or "fts" in name:
continue
if not name.replace("_", "").isalnum():
continue
if "asset_id" in await _existing_columns(db, name):
out.append(name)
return out
def _pick_keeper(rows: list[dict[str, Any]]) -> dict[str, Any]:
for row in rows:
fp = str(row.get("filepath") or "")
if fp and fp == _canonical(fp):
return row
return min(rows, key=lambda r: int(r.get("id") or 0))
async def _merge_group(db: Any, keep_id: int, dup_ids: list[int], other_tables: list[str]) -> None:
placeholders = ",".join("?" for _ in dup_ids)
all_ids = (keep_id, *dup_ids)
all_ph = ",".join("?" for _ in all_ids)
# Metadata row: move one over if the keeper has none, then keep the best rating.
for dup in dup_ids:
await db.aexecute("UPDATE OR IGNORE asset_metadata SET asset_id = ? WHERE asset_id = ?", (keep_id, dup))
await db.aexecute(
f"UPDATE asset_metadata SET rating = (SELECT MAX(COALESCE(rating, 0)) FROM asset_metadata "
f"WHERE asset_id IN ({all_ph})) WHERE asset_id = ?",
(*all_ids, keep_id),
)
# Tags: INSERT (not UPDATE) so the asset_tags insert trigger refreshes FTS.
await db.aexecute(
f"INSERT OR IGNORE INTO asset_tags(asset_id, tag_id) "
f"SELECT ?, tag_id FROM asset_tags WHERE asset_id IN ({placeholders})",
(keep_id, *dup_ids),
)
await db.aexecute(
f"UPDATE asset_stacks SET cover_asset_id = ? WHERE cover_asset_id IN ({placeholders})",
(keep_id, *dup_ids),
)
columns = await _existing_columns(db, "assets")
for col in _COPY_IF_EMPTY_COLUMNS:
if col not in columns:
continue
await db.aexecute(
f"UPDATE assets SET {col} = (SELECT {col} FROM assets WHERE id IN ({placeholders}) "
f"AND {col} IS NOT NULL AND {col} != '' LIMIT 1) "
f"WHERE id = ? AND ({col} IS NULL OR {col} = '')",
(*dup_ids, keep_id),
)
for table in other_tables:
await db.aexecute(
f"UPDATE OR IGNORE {table} SET asset_id = ? WHERE asset_id IN ({placeholders})",
(keep_id, *dup_ids),
)
try:
await db.aexecute(
f"UPDATE OR IGNORE vec.asset_embeddings SET asset_id = ? WHERE asset_id IN ({placeholders})",
(keep_id, *dup_ids),
)
await db.aexecute(
f"DELETE FROM vec.asset_embeddings WHERE asset_id IN ({placeholders})", tuple(dup_ids)
)
except Exception:
logger.debug("vector embeddings merge skipped", exc_info=True)
await db.aexecute(f"DELETE FROM assets WHERE id IN ({placeholders})", tuple(dup_ids))
async def merge_duplicate_asset_rows(db: Any, *, case_insensitive: bool | None = None) -> Result[int]:
"""Collapse rows that are the same file on a case-insensitive filesystem.
Returns the number of duplicate rows removed (0 when nothing to do).
"""
if case_insensitive is None:
case_insensitive = os.name == "nt"
if not case_insensitive:
return Result.Ok(0)
try:
candidates = await _rows(
db,
"SELECT id, filepath FROM assets WHERE LOWER(REPLACE(filepath, '\\', '/')) IN ("
"SELECT LOWER(REPLACE(filepath, '\\', '/')) FROM assets "
"GROUP BY LOWER(REPLACE(filepath, '\\', '/')) HAVING COUNT(*) > 1)",
)
if not candidates:
return Result.Ok(0)
groups: dict[str, list[dict[str, Any]]] = {}
for row in candidates:
groups.setdefault(_group_key(str(row.get("filepath") or "")), []).append(row)
other_tables = await _other_asset_id_tables(db)
removed = 0
async with db.atransaction(mode="immediate") as tx:
if not tx.ok:
return Result.Err("DB_ERROR", tx.error or "Failed to begin transaction")
for rows in groups.values():
if len(rows) < 2:
continue
keeper = _pick_keeper(rows)
keep_id = int(keeper["id"])
dup_ids = [int(r["id"]) for r in rows if int(r["id"]) != keep_id]
await _merge_group(db, keep_id, dup_ids, other_tables)
removed += len(dup_ids)
if not tx.ok:
return Result.Err("DB_ERROR", tx.error or "Commit failed")
if removed:
logger.info("Merged %s duplicate index row(s) that pointed at the same file", removed)
return Result.Ok(removed)
except Exception as exc:
logger.warning("Duplicate asset row merge skipped: %s", exc)
return Result.Err("DB_ERROR", str(exc))
__all__ = ["merge_duplicate_asset_rows"]
+20 -12
View File
@@ -212,7 +212,16 @@ async def _assets_fts_needs_rebuild(db) -> bool:
ddl_lower = (await _sqlite_object_sql(db, "table", "assets_fts")).lower()
trig_row = await db.aquery("SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE 'assets_fts_%'")
triggers_exist = bool(trig_row.ok and trig_row.data)
return bool((not ddl_lower) or ("fts5" not in ddl_lower) or (not triggers_exist))
if (not ddl_lower) or ("fts5" not in ddl_lower) or (not triggers_exist):
return True
# Databases created before the fix carry delete/update triggers that issue a
# plain DELETE/UPDATE on this external-content table; that corrupts the
# index on every rename/move. Replace them (and rebuild the index) once.
for trigger in ("assets_fts_delete", "assets_fts_update"):
sql_lower = " ".join((await _sqlite_object_sql(db, "trigger", trigger)).lower().split())
if "'delete'" not in sql_lower:
return True
return False
async def _drop_assets_fts_objects(db) -> None:
@@ -248,25 +257,24 @@ async def _create_assets_fts_triggers(db) -> None:
END;
CREATE TRIGGER IF NOT EXISTS assets_fts_delete AFTER DELETE ON assets BEGIN
DELETE FROM assets_fts WHERE rowid = old.id;
INSERT INTO assets_fts(assets_fts, rowid, filename, subfolder)
VALUES ('delete', old.id, old.filename, old.subfolder);
END;
CREATE TRIGGER IF NOT EXISTS assets_fts_update AFTER UPDATE ON assets BEGIN
UPDATE assets_fts SET filename = new.filename, subfolder = new.subfolder
WHERE rowid = new.id;
CREATE TRIGGER IF NOT EXISTS assets_fts_update AFTER UPDATE OF filename, subfolder ON assets BEGIN
INSERT INTO assets_fts(assets_fts, rowid, filename, subfolder)
VALUES ('delete', old.id, old.filename, old.subfolder);
INSERT INTO assets_fts(rowid, filename, subfolder)
VALUES (new.id, new.filename, new.subfolder);
END;
"""
)
async def _rebuild_assets_fts_content(db) -> None:
await db.aexecutescript(
"""
DELETE FROM assets_fts;
INSERT INTO assets_fts(rowid, filename, subfolder)
SELECT id, COALESCE(filename, ''), COALESCE(subfolder, '') FROM assets;
"""
)
# External-content FTS5: 'rebuild' re-reads every row of ``assets``; a plain
# DELETE FROM would go through the content table and is not allowed here.
await db.aexecutescript("INSERT INTO assets_fts(assets_fts) VALUES('rebuild');")
async def repair_assets_fts(db) -> Result[bool]:
+12 -4
View File
@@ -242,13 +242,21 @@ CREATE TRIGGER IF NOT EXISTS assets_fts_insert AFTER INSERT ON assets BEGIN
VALUES (new.id, new.filename, new.subfolder);
END;
-- assets_fts is an external-content FTS5 table (content='assets'): rows must be
-- removed with the special 'delete' command carrying the OLD values. A plain
-- DELETE/UPDATE on it makes FTS5 re-read the content table, which an AFTER
-- trigger has already changed, and fails with "database disk image is
-- malformed" whenever filename/subfolder change (rename, move).
CREATE TRIGGER IF NOT EXISTS assets_fts_delete AFTER DELETE ON assets BEGIN
DELETE FROM assets_fts WHERE rowid = old.id;
INSERT INTO assets_fts(assets_fts, rowid, filename, subfolder)
VALUES ('delete', old.id, old.filename, old.subfolder);
END;
CREATE TRIGGER IF NOT EXISTS assets_fts_update AFTER UPDATE ON assets BEGIN
UPDATE assets_fts SET filename = new.filename, subfolder = new.subfolder
WHERE rowid = new.id;
CREATE TRIGGER IF NOT EXISTS assets_fts_update AFTER UPDATE OF filename, subfolder ON assets BEGIN
INSERT INTO assets_fts(assets_fts, rowid, filename, subfolder)
VALUES ('delete', old.id, old.filename, old.subfolder);
INSERT INTO assets_fts(rowid, filename, subfolder)
VALUES (new.id, new.filename, new.subfolder);
END;
CREATE TRIGGER IF NOT EXISTS asset_metadata_fts_insert AFTER INSERT ON asset_metadata BEGIN
+8 -1
View File
@@ -377,7 +377,14 @@ async def _create_watcher(index_service: IndexService) -> OutputWatcher:
logger.debug("Watcher move callback failed (%s -> %s): %s", old_fp, new_fp, exc)
continue
watcher = OutputWatcher(index_callback, remove_callback=remove_callback, move_callback=move_callback)
from .routes.handlers.scan_watcher import build_sidecar_reindex_callback
watcher = OutputWatcher(
index_callback,
remove_callback=remove_callback,
move_callback=move_callback,
reindex_callback=build_sidecar_reindex_callback(index_service),
)
# Collect directories to watch
try:
@@ -399,7 +399,10 @@ async def prepare_single_batch_entry(
),
None,
)
if has_cached_miss and existing_id in has_rich_meta_set:
# Incremental shortcut only: a non-incremental re-index (e.g. a sidecar
# JSON appeared next to an unchanged file and its cache was invalidated)
# must re-extract instead of trusting the existing metadata row.
if incremental and has_cached_miss and existing_id in has_rich_meta_set:
return {"action": "skipped"}, None
if fast:
+73 -7
View File
@@ -1,4 +1,13 @@
"""Prune indexed assets that disappeared from disk during a directory scan."""
"""Prune indexed assets that disappeared from disk during a directory scan.
Like ComfyUI core's asset reconciler (``app/assets/scanner_changes.py``), a
file is only treated as gone when the filesystem positively says so. A stat
that fails for any other reason (permission error, network share hiccup,
unmounted drive) is "unknown" and never deletes a row -- deleting it would
throw away the user's ratings, tags and collections for a file that is
still there. Removed ids are announced with a ``mjr-assets-removed`` event so
open grids drop the cards without a full reload.
"""
from __future__ import annotations
@@ -30,16 +39,60 @@ def _is_direct_child(filepath: str, root: str) -> bool:
return False
def _path_exists(filepath: str) -> bool:
_MAX_REMOVED_IDS_IN_EVENT = 1000
def _path_state(filepath: str) -> bool | None:
"""True = on disk, False = definitely gone, None = could not tell."""
try:
return Path(filepath).exists()
os.stat(filepath)
return True
except (FileNotFoundError, NotADirectoryError):
return False
except OSError:
return None
except Exception:
return None
def _path_exists(filepath: str) -> bool:
"""Backward-compatible helper: only a *confirmed* absence returns False."""
return _path_state(filepath) is not False
def _root_is_readable(directory: str) -> bool:
"""A scan root that cannot be listed (unmounted, offline share) must not be pruned."""
try:
with os.scandir(directory) as entries:
next(entries, None)
return True
except OSError:
return False
def _find_missing_filepaths(candidates: list[tuple[int, str]]) -> list[int]:
"""Stat every candidate off the event loop; return asset ids missing from disk."""
return [asset_id for asset_id, filepath in candidates if not _path_exists(filepath)]
"""Stat every candidate off the event loop; return asset ids confirmed missing."""
return [asset_id for asset_id, filepath in candidates if _path_state(filepath) is False]
def _emit_removed(asset_ids: list[int], *, reason: str, directory: str = "") -> None:
if not asset_ids:
return
try:
from ...adapters.comfy_events import emit_event
emit_event(
"mjr-assets-removed",
{
"ids": [int(i) for i in asset_ids[:_MAX_REMOVED_IDS_IN_EVENT]],
"count": len(asset_ids),
"truncated": len(asset_ids) > _MAX_REMOVED_IDS_IN_EVENT,
"reason": reason,
"directory": directory,
},
)
except Exception:
logger.debug("_emit_removed: suppressed exception", exc_info=True)
def _source_root_clause(source: str, root_id: str | None) -> tuple[str, list[Any]]:
@@ -92,10 +145,13 @@ async def _delete_asset_ids(scanner: Any, asset_ids: list[int]) -> Result[int]:
)
if not res.ok:
return Result.Err("DB_ERROR", res.error or "Failed to prune stale assets")
# ``res.data`` is SQLite's change count, which also includes rows touched
# by ON DELETE CASCADE and FTS triggers; report assets, not rows.
try:
total += int(res.data or 0)
changed = int(res.data or 0)
except Exception:
total += len(batch)
changed = len(batch)
total += min(len(batch), changed) if changed > 0 else 0
return Result.Ok(total)
@@ -134,6 +190,15 @@ async def prune_missing_assets_after_scan(
if not candidates:
return Result.Ok(0)
if not await asyncio.to_thread(_root_is_readable, directory):
logger.warning(
"Skipping prune: scan root %s is not readable (offline or unmounted?); "
"%s indexed asset(s) kept",
directory,
len(candidates),
)
return Result.Ok(0)
stale_ids = await asyncio.to_thread(_find_missing_filepaths, candidates)
if not stale_ids:
return Result.Ok(0)
@@ -145,6 +210,7 @@ async def prune_missing_assets_after_scan(
int(deleted_res.data or 0),
directory,
)
_emit_removed(stale_ids, reason="scan_prune", directory=directory)
return deleted_res
+107 -33
View File
@@ -108,18 +108,78 @@ def _normalize_sort_key(sort: str | None) -> str:
def _strip_diacritics(text: str) -> str:
normalized = unicodedata.normalize("NFKD", text)
return "".join(ch for ch in normalized if not unicodedata.combining(ch))
"""Fold accents on Latin letters only (``é`` -> ``e``).
Mirrors FTS5's ``unicode61`` tokenizer (``remove_diacritics``), which folds
Latin script only. Folding e.g. Cyrillic ``й`` to ``и`` here would make the
query miss an index that still stores ``й``.
"""
out: list[str] = []
for ch in text:
decomposed = unicodedata.normalize("NFKD", ch)
base = "".join(c for c in decomposed if not unicodedata.combining(c))
out.append(base if base.isascii() and base.isalpha() else ch)
return "".join(out)
# Word characters in any script (letters, digits, underscore). Every token
# built from these is a valid FTS5 bareword once lower-cased: FTS5 operators
# (AND/OR/NOT/NEAR) are only operators in upper case.
_SEARCH_TOKEN_RE = re.compile(r"\w+", re.UNICODE)
# Rank given to rows that only match by substring: worse than any bm25 score
# (bm25 is negative, lower is better), so word/prefix hits stay on top.
_SUBSTRING_MATCH_RANK = 50.0
def _filename_substring_pattern(query: str) -> str | None:
"""``LIKE`` pattern for "file name contains <query>" (single-word queries).
FTS5 only matches words that *start* with the query: typing ``f`` found
``field.png`` but not ``sunflower.png`` or ``ComfyUI_00001_.png``.
Single-word searches therefore also match file names containing the text.
Multi-word and quoted searches stay pure FTS.
"""
text = str(query or "").strip()
if not text or text == "*" or any(ch.isspace() for ch in text) or text[0] in "\"'":
return None
escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"
def _substring_matches_sql(pattern: str | None, *, with_rank: bool) -> str:
if not pattern:
return ""
if with_rank:
return (
"\n UNION ALL\n\n"
f" SELECT id AS asset_id, {_SUBSTRING_MATCH_RANK} AS rank\n"
" FROM assets\n"
" WHERE filename LIKE ? ESCAPE '\\'\n"
)
return (
"\n UNION\n\n"
" SELECT id AS asset_id\n"
" FROM assets\n"
" WHERE filename LIKE ? ESCAPE '\\'\n"
)
def _is_browse_query(query: str) -> bool:
"""``*`` or input without any word character (a lone quote while typing a
phrase, "--", "()"): nothing to match, so browse instead of erroring."""
return not _SEARCH_TOKEN_RE.search(str(query or ""))
def _search_tokens(text: str) -> list[str]:
return [tok.lower() for tok in _SEARCH_TOKEN_RE.findall(_strip_diacritics(text))]
def _extract_quoted_phrases(text: str) -> list[str]:
phrases: list[str] = []
for match in re.finditer(r'"([^"]+)"|\'([^\']+)\'', text):
phrase = match.group(1) or match.group(2) or ""
phrase = _strip_diacritics(phrase)
phrase = re.sub(r"[\"'\-:&/\\|;@#*~()\[\]{}\.]+", " ", phrase)
phrase = re.sub(r"[^\x20-\x7E]+", " ", phrase)
phrase = re.sub(r"\s+", " ", phrase).strip().lower()
phrase = " ".join(_search_tokens(match.group(1) or match.group(2) or ""))
if phrase:
phrases.append(phrase)
# Keep phrase clauses bounded for MATCH stability.
@@ -1223,13 +1283,16 @@ class IndexSearcher:
filters: dict[str, Any] | None,
include_total: bool,
metadata_tags_text_clause: str,
substring_pattern: str | None = None,
) -> Result[dict[str, Any]]:
# Never use COUNT(*) OVER() here — on large result sets the window
# function forces SQLite to materialise every matching row before it
# can return page 1, making first-page loads slow at scale.
# _global_fts_total_count runs a lean UNION count query instead.
sql_parts = [self._global_fts_select_sql("", metadata_tags_text_clause)]
sql_parts = [self._global_fts_select_sql("", metadata_tags_text_clause, substring_pattern)]
params: list[Any] = [fts_query, fts_query]
if substring_pattern:
params.append(substring_pattern)
filter_clauses, filter_params = self._filter_clauses(filters)
sql_parts.extend(filter_clauses)
@@ -1249,11 +1312,15 @@ class IndexSearcher:
total: int | None = None
if include_total:
total = await self._global_fts_total_count(fts_query, filter_clauses, filter_params)
total = await self._global_fts_total_count(
fts_query, filter_clauses, filter_params, substring_pattern=substring_pattern
)
return Result.Ok({"rows": rows, "total": total})
@staticmethod
def _global_fts_select_sql(total_field: str, metadata_tags_text_clause: str) -> str:
def _global_fts_select_sql(
total_field: str, metadata_tags_text_clause: str, substring_pattern: str | None = None
) -> str:
return f"""
WITH matches AS (
SELECT rowid AS asset_id, bm25(assets_fts, 8.0, 1.25) AS rank
@@ -1265,7 +1332,7 @@ class IndexSearcher:
SELECT rowid AS asset_id, (bm25(asset_metadata_fts, 7.0, 4.0, 1.5) + 2.0) AS rank
FROM asset_metadata_fts
WHERE asset_metadata_fts MATCH ?
),
{_substring_matches_sql(substring_pattern, with_rank=True)} ),
best AS (
SELECT asset_id, MIN(rank) AS rank
FROM matches
@@ -1297,8 +1364,10 @@ class IndexSearcher:
fts_query: str,
filter_clauses: list[str],
filter_params: list[Any],
*,
substring_pattern: str | None = None,
) -> int:
count_sql = """
count_sql = f"""
WITH matches AS (
SELECT rowid AS asset_id
FROM assets_fts
@@ -1309,7 +1378,7 @@ class IndexSearcher:
SELECT rowid AS asset_id
FROM asset_metadata_fts
WHERE asset_metadata_fts MATCH ?
)
{_substring_matches_sql(substring_pattern, with_rank=False)} )
SELECT COUNT(*) as total
FROM (SELECT DISTINCT asset_id FROM matches) t
JOIN assets a ON t.asset_id = a.id
@@ -1317,6 +1386,8 @@ class IndexSearcher:
WHERE 1=1
"""
count_params: list[Any] = [fts_query, fts_query]
if substring_pattern:
count_params.append(substring_pattern)
if filter_clauses:
count_sql += " " + " ".join(filter_clauses)
count_params.extend(filter_params)
@@ -1414,6 +1485,7 @@ class IndexSearcher:
include_total: bool,
metadata_tags_text_clause: str,
sort: str | None,
substring_pattern: str | None = None,
) -> Result[dict[str, Any]]:
sql_parts = [
f"""
@@ -1427,7 +1499,7 @@ class IndexSearcher:
SELECT rowid AS asset_id, (bm25(asset_metadata_fts, 7.0, 4.0, 1.5) + 2.0) AS rank
FROM asset_metadata_fts
WHERE asset_metadata_fts MATCH ?
),
{_substring_matches_sql(substring_pattern, with_rank=True)} ),
best AS (
SELECT asset_id, MIN(rank) AS rank
FROM matches
@@ -1453,6 +1525,8 @@ class IndexSearcher:
"""
]
params: list[Any] = [fts_query, fts_query]
if substring_pattern:
params.append(substring_pattern)
params.extend(roots_params)
filter_clauses, filter_params = self._filter_clauses(filters, assert_safe=True)
@@ -1484,7 +1558,7 @@ class IndexSearcher:
SELECT rowid AS asset_id
FROM asset_metadata_fts
WHERE asset_metadata_fts MATCH ?
)
{_substring_matches_sql(substring_pattern, with_rank=False)} )
SELECT COUNT(*) as total
FROM (SELECT DISTINCT asset_id FROM matches) t
JOIN assets a ON t.asset_id = a.id
@@ -1492,6 +1566,8 @@ class IndexSearcher:
WHERE {roots_clause}
"""
count_params: list[Any] = [fts_query, fts_query]
if substring_pattern:
count_params.append(substring_pattern)
count_params.extend(roots_params)
if filter_clauses:
count_sql += " " + " ".join(filter_clauses)
@@ -1674,7 +1750,7 @@ class IndexSearcher:
sort: str | None,
cursor: str | None = None,
):
is_browse_all = query.strip() == "*"
is_browse_all = _is_browse_query(query)
if roots is None:
async def _fetch_global_group_rows(raw_limit: int, raw_offset: int) -> Result[dict[str, Any]]:
@@ -1693,6 +1769,7 @@ class IndexSearcher:
return Result.Err("INVALID_INPUT", "Invalid search query syntax")
return await self._search_global_fts_rows(
fts_query=fts_query,
substring_pattern=_filename_substring_pattern(query),
limit=raw_limit,
offset=raw_offset,
filters=filters,
@@ -1722,6 +1799,7 @@ class IndexSearcher:
return Result.Err("INVALID_INPUT", "Invalid search query syntax")
return await self._search_scoped_fts_rows(
fts_query=fts_query,
substring_pattern=_filename_substring_pattern(query),
roots_clause=roots_clause,
roots_params=roots_params,
limit=raw_limit,
@@ -1746,7 +1824,7 @@ class IndexSearcher:
include_highlight: bool,
cursor: str | None = None,
) -> Result[dict[str, Any]]:
if query.strip() == "*":
if _is_browse_query(query):
rows_total_res = await self._search_global_browse_rows(
limit=limit,
offset=offset,
@@ -1761,6 +1839,7 @@ class IndexSearcher:
return Result.Err("INVALID_INPUT", "Invalid search query syntax")
rows_total_res = await self._search_global_fts_rows(
fts_query=fts_query,
substring_pattern=_filename_substring_pattern(query),
limit=limit,
offset=offset,
filters=filters,
@@ -1793,7 +1872,7 @@ class IndexSearcher:
cursor: str | None = None,
) -> Result[dict[str, Any]]:
roots_clause, roots_params = _build_roots_where_clause(roots)
if query.strip() == "*":
if _is_browse_query(query):
rows_total_res = await self._search_scoped_browse_rows(
roots_clause=roots_clause,
roots_params=roots_params,
@@ -1811,6 +1890,7 @@ class IndexSearcher:
return Result.Err("INVALID_INPUT", "Invalid search query syntax")
rows_total_res = await self._search_scoped_fts_rows(
fts_query=fts_query,
substring_pattern=_filename_substring_pattern(query),
roots_clause=roots_clause,
roots_params=roots_params,
limit=limit,
@@ -2108,21 +2188,15 @@ class IndexSearcher:
phrases = _extract_quoted_phrases(text)
text_wo_quotes = re.sub(r'"[^"]*"|\'[^\']*\'', " ", text)
sanitized = _strip_diacritics(text_wo_quotes)
# Replace FTS5 special characters with spaces.
sanitized = re.sub(r"[\"'\-:&/\\|;@#*~()\[\]{}\.]+", " ", sanitized)
# Replace non-printable / control chars
sanitized = re.sub(r"[^\x20-\x7E]+", " ", sanitized)
# Collapse whitespace
sanitized = re.sub(r"\s+", " ", sanitized).strip()
# Normalize case for stable behavior (FTS is case-insensitive anyway).
sanitized = sanitized.lower()
# Apply prefix matching to every token to allow partial matches.
# Avoid FTS reserved operators to prevent malformed MATCH expressions.
tokens = [tok for tok in sanitized.split() if _is_meaningful_token(tok)]
tokens = _dedupe_tokens(tokens)
# Word tokens only: punctuation (- : . ( ) " * ...) never reaches MATCH.
raw_tokens = _dedupe_tokens(_search_tokens(text_wo_quotes))
# Drop stopwords, 1-char tokens and operator words for relevance...
tokens = [tok for tok in raw_tokens if _is_meaningful_token(tok)]
if not tokens and not phrases:
# ...unless that leaves nothing. "at-7", "(2)", "AND" or a single
# letter typed in search-as-you-type used to become an empty MATCH
# and fail with "Invalid search query syntax".
tokens = raw_tokens
token_expr = _build_token_match_expression(tokens)
phrase_terms = [f'"{phrase}"' for phrase in phrases]
+99 -2
View File
@@ -319,6 +319,7 @@ class IndexService:
"""
# ON DELETE CASCADE handles asset_metadata, scan_journal, etc.
where_sql, where_params = _filepath_match_clause(filepath, column="filepath")
removed_ids = await self._asset_ids_where(where_sql, where_params)
res = await self.db.aexecute(
f"DELETE FROM assets WHERE {where_sql}",
where_params,
@@ -333,8 +334,76 @@ class IndexService:
if deleted_rows <= 0:
return Result.Err("NOT_FOUND", "Asset not found")
logger.debug(f"Removed from index: {filepath}")
self._emit_assets_removed(removed_ids, reason="file_deleted")
return Result.Ok(True)
async def invalidate_cached_metadata(self, filepaths: list[Any]) -> int:
"""Forget cached extraction results for ``filepaths``.
The metadata cache and scan journal are keyed by the media file's own
state (path, mtime, size). Data that lives *next to* the file -- a
sidecar JSON added or edited later -- does not change that key, so
without this a re-index would reuse the stale cached result.
"""
cleared = 0
for raw in filepaths or []:
for table in ("metadata_cache", "scan_journal"):
try:
where_sql, where_params = _filepath_match_clause(str(raw), column="filepath")
res = await self.db.aexecute(f"DELETE FROM {table} WHERE {where_sql}", where_params)
if res.ok:
cleared += int(res.data or 0)
except Exception:
logger.debug("invalidate_cached_metadata: suppressed exception", exc_info=True)
return cleared
async def _asset_ids_where(self, where_sql: str, where_params: Any) -> list[int]:
try:
res = await self.db.aquery(f"SELECT id FROM assets WHERE {where_sql}", where_params)
except Exception:
return []
if not res.ok or not res.data:
return []
ids: list[int] = []
for row in res.data:
try:
ids.append(int(row["id"] if isinstance(row, dict) else row[0]))
except Exception:
continue
return ids
@staticmethod
def _emit_assets_removed(asset_ids: list[int], *, reason: str) -> None:
"""Tell open grids that files vanished outside Majoor (watcher, external delete)."""
if not asset_ids:
return
try:
from ...adapters.comfy_events import emit_event
emit_event(
"mjr-assets-removed",
{"ids": list(asset_ids), "count": len(asset_ids), "reason": reason},
)
except Exception as exc:
logger.debug("Failed to emit mjr-assets-removed: %s", exc)
async def _emit_asset_moved(self, new_filepath: str) -> None:
"""A move keeps the asset id; push the new path so cards update in place."""
try:
from ...adapters.comfy_events import emit_event
where_sql, where_params = _filepath_match_clause(new_filepath, column="filepath")
ids = await self._asset_ids_where(where_sql, where_params)
if not ids:
return
batch_res = await self.get_assets_batch(ids[:BATCH_ASSET_PUSH_LIMIT])
if not batch_res.ok or not batch_res.data:
return
for asset in batch_res.data:
emit_event("mjr-asset-updated", sanitize_for_json(dict(asset)))
except Exception as exc:
logger.debug("Failed to emit moved asset update: %s", exc)
async def rename_file(self, old_filepath: str, new_filepath: str) -> Result[bool]:
"""
Rename/move a file path in index tables without rescanning directories.
@@ -355,13 +424,41 @@ class IndexService:
return Result.Err("DB_ERROR", str(exc))
logger.debug("Renamed in index: %s -> %s", old_fp, new_fp)
await self._emit_asset_moved(new_fp)
return Result.Ok(True)
@staticmethod
def _scan_complete_payload(data: Any, *, origin: str) -> Any:
"""Annotate scan stats for the UI.
``needs_refresh`` is true when the change cannot be shown with live
per-asset events: a directory scan pushes none, and ``index_paths`` only
pushes the first ``BATCH_ASSET_PUSH_LIMIT`` new assets. Pruned rows are
announced separately with ``mjr-assets-removed``.
"""
if not isinstance(data, dict):
return data
payload = dict(data)
try:
added = int(payload.get("added") or 0)
except (TypeError, ValueError):
added = 0
try:
pruned = int(payload.get("pruned") or 0)
except (TypeError, ValueError):
pruned = 0
live_limit = 0 if origin == "directory_scan" else BATCH_ASSET_PUSH_LIMIT
payload["origin"] = origin
payload["needs_refresh"] = added > live_limit or pruned > 0
payload.pop("added_ids", None)
payload.pop("to_enrich", None)
return payload
def _emit_scan_complete_event(self, data: Any) -> None:
try:
from ...adapters.comfy_events import emit_event
emit_event("mjr-scan-complete", data)
emit_event("mjr-scan-complete", self._scan_complete_payload(data, origin="directory_scan"))
except Exception as exc:
logger.debug("Failed to emit scan-complete event: %s", exc)
@@ -386,7 +483,7 @@ class IndexService:
except Exception:
return
try:
emit_event("mjr-scan-complete", data)
emit_event("mjr-scan-complete", self._scan_complete_payload(data, origin="index_paths"))
emit_event(
"mjr.scan.progress",
sanitize_for_json(
+78
View File
@@ -93,6 +93,35 @@ def _is_supported_extension(ext: str) -> bool:
return False
return ext in SUPPORTED_EXTENSIONS
def sidecar_media_paths(json_path: str) -> list[str]:
"""Existing media files a sidecar JSON belongs to.
``shot.png.json`` -> ``shot.png``; ``shot.json`` -> every ``shot.<media ext>``
in the same folder (case-insensitive stem match).
"""
path = str(json_path or "")
if not path.lower().endswith(".json"):
return []
base = path[: -len(".json")]
if _is_supported_extension(Path(base).suffix.lower()):
return [base] if os.path.isfile(base) else []
folder = os.path.dirname(base) or "."
stem = os.path.basename(base).lower()
out: list[str] = []
try:
with os.scandir(folder) as entries:
for entry in entries:
name = entry.name
root, ext = os.path.splitext(name)
if root.lower() != stem or not _is_supported_extension(ext.lower()):
continue
if entry.is_file():
out.append(os.path.join(folder, name))
except OSError:
return []
return out
# Directories to always ignore (case-insensitive on Windows)
IGNORED_DIRS = {
"_mjr_index",
@@ -152,9 +181,12 @@ class DebouncedWatchHandler(FileSystemEventHandler):
debounce_ms: int = 500,
dedupe_ttl_ms: int = 3000,
flush_concurrency: int = 1,
on_sidecar_changed: Callable[[list], Awaitable[None]] | None = None,
):
super().__init__()
self._on_files_ready = on_files_ready
self._on_sidecar_changed = on_sidecar_changed
self._sidecar_recent: dict[str, float] = {}
self._on_files_removed = on_files_removed
self._on_files_moved = on_files_moved
self._loop = loop
@@ -301,6 +333,9 @@ class DebouncedWatchHandler(FileSystemEventHandler):
"""Queue a file for indexing with deduplication."""
if not path:
return
if path.lower().endswith(".json"):
self._handle_sidecar_json(path)
return
# Fast rejections — settings refresh happens inside _schedule_flush (MED-03: was double-called)
if self._is_ignored_path(path):
@@ -342,6 +377,38 @@ class DebouncedWatchHandler(FileSystemEventHandler):
# Schedule flush
self._schedule_flush()
def _handle_sidecar_json(self, json_path: str) -> None:
"""A sidecar ``<file>.json`` / ``<stem>.json`` appeared or changed.
The media it describes did not change, so an incremental index would
skip it and never read the new workflow/prompt. Ask for a forced
re-index of the sibling media file(s) instead.
"""
if not self._on_sidecar_changed or self._is_ignored_path(json_path):
return
media = [m for m in sidecar_media_paths(json_path) if not self._is_ignored_path(m)]
if not media:
return
now = time.time()
fresh: list[str] = []
with self._lock:
for media_path in media:
key = self._normalize_path(media_path)
if now - self._sidecar_recent.get(key, 0.0) < max(1.0, self._dedupe_ttl_s):
continue
self._sidecar_recent[key] = now
fresh.append(media_path)
if len(self._sidecar_recent) > 2048:
cutoff = now - 60.0
self._sidecar_recent = {k: t for k, t in self._sidecar_recent.items() if t >= cutoff}
if not fresh:
return
try:
coro = cast(Coroutine[Any, Any, None], self._on_sidecar_changed(fresh))
asyncio.run_coroutine_threadsafe(coro, self._loop)
except Exception:
logger.debug("_handle_sidecar_json: suppressed exception", exc_info=True)
def _schedule_flush(self):
"""Schedule a debounced flush of pending files.
@@ -755,12 +822,16 @@ class OutputWatcher:
index_callback: Callable[[list, str, str | None, str | None], Awaitable[None]],
remove_callback: Callable[[list, str, str | None, str | None], Awaitable[None]] | None = None,
move_callback: Callable[[list, str, str | None, str | None], Awaitable[None]] | None = None,
reindex_callback: Callable[[list, str, str | None, str | None], Awaitable[None]] | None = None,
):
"""
Args:
index_callback: async function(filepaths, base_dir, source, root_id) to index files
reindex_callback: same signature, forces re-extraction (used when a
sidecar JSON next to an unchanged media file appears or changes)
"""
self._index_callback = index_callback
self._reindex_callback = reindex_callback
self._remove_callback = remove_callback
self._move_callback = move_callback
self._observer: Any | None = None
@@ -798,6 +869,7 @@ class OutputWatcher:
debounce_ms=WATCHER_DEBOUNCE_MS,
dedupe_ttl_ms=WATCHER_DEDUPE_TTL_MS,
flush_concurrency=WATCHER_MAX_FLUSH_CONCURRENCY,
on_sidecar_changed=self._handle_sidecar_media if self._reindex_callback else None,
)
self._observer = Observer()
@@ -830,6 +902,12 @@ class OutputWatcher:
grouped = self._group_files_by_watched_root(files)
await self._dispatch_file_groups(grouped, self._index_callback, "index")
async def _handle_sidecar_media(self, files: list) -> None:
if not files or not callable(self._reindex_callback):
return
grouped = self._group_files_by_watched_root(files)
await self._dispatch_file_groups(grouped, self._reindex_callback, "reindex")
async def _handle_removed_files(self, files: list) -> None:
if not files or not callable(self._remove_callback):
return
@@ -15,16 +15,14 @@ from typing import Any
from ...shared import Result, get_logger
from .parsing_utils import (
looks_like_comfyui_prompt_graph,
looks_like_comfyui_workflow,
try_parse_json_text,
)
from .sidecar import find_sidecar_json, load_sidecar_metadata, read_sidecar_json
logger = get_logger(__name__)
# Limits to prevent unbounded reads
_MAX_GLB_JSON_CHUNK_BYTES = 10 * 1024 * 1024 # 10 MB (reduced from 50 MB to limit DoS surface)
_MAX_SIDECAR_FILE_BYTES = 10 * 1024 * 1024 # 10 MB
# GLB binary container constants (glTF 2.0 spec)
_GLB_MAGIC = 0x46546C67 # "glTF" in little-endian
@@ -135,71 +133,6 @@ def _read_gltf_file(file_path: str) -> dict[str, Any] | None:
return None
def _read_sidecar_json(file_path: str) -> dict[str, Any] | None:
"""Look for a sidecar JSON file next to the 3D file.
Checks for ``<file>.json`` (e.g. ``model.glb.json``, ``mesh.obj.json``).
"""
sidecar_path = file_path + ".json"
if not os.path.isfile(sidecar_path):
return None
try:
size = os.path.getsize(sidecar_path)
if size > _MAX_SIDECAR_FILE_BYTES:
logger.warning(f"Sidecar JSON too large ({size} bytes): {sidecar_path}")
return None
with open(sidecar_path, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.debug(f"Failed to read sidecar JSON {sidecar_path}: {exc}")
return None
def _extract_typed_field(
raw: Any,
predicate: Any,
) -> Any | None:
if isinstance(raw, dict) and predicate(raw):
return raw
if isinstance(raw, str):
parsed = try_parse_json_text(raw)
if parsed and predicate(parsed):
return parsed
return None
def _infer_sidecar_root(
data: dict[str, Any],
) -> tuple[Any | None, Any | None]:
if looks_like_comfyui_prompt_graph(data):
return None, data
if looks_like_comfyui_workflow(data):
return data, None
return None, None
def _classify_sidecar_data(data: dict[str, Any]) -> dict[str, Any]:
"""Classify sidecar JSON content as workflow, prompt, or parameters."""
result: dict[str, Any] = {"workflow": None, "prompt": None}
result["workflow"] = _extract_typed_field(
data.get("workflow"), looks_like_comfyui_workflow
)
result["prompt"] = _extract_typed_field(
data.get("prompt"), looks_like_comfyui_prompt_graph
)
if not result["workflow"] and not result["prompt"]:
result["workflow"], result["prompt"] = _infer_sidecar_root(data)
for key in ("parameters", "geninfo", "generation_time_ms"):
if key in data and key not in result:
result[key] = data[key]
return result
def _extract_embedded_3d_json(file_path: str, ext: str) -> dict[str, Any] | None:
if ext == ".glb":
return _read_glb_json_chunk(file_path)
@@ -208,11 +141,15 @@ def _extract_embedded_3d_json(file_path: str, ext: str) -> dict[str, Any] | None
return None
def _read_sidecar_json(file_path: str) -> dict[str, Any] | None:
"""Raw sidecar object for ``file_path`` (``<file>.json`` or ``<stem>.json``)."""
sidecar_path = find_sidecar_json(file_path)
return read_sidecar_json(sidecar_path) if sidecar_path else None
def _extract_sidecar_metadata(file_path: str) -> dict[str, Any] | None:
sidecar = _read_sidecar_json(file_path)
if not sidecar:
return None
return _classify_sidecar_data(sidecar)
# Shared with images/videos/audio: <file>.json, then <stem>.json.
return load_sidecar_metadata(file_path)
def _determine_quality(workflow: Any, prompt: Any) -> str:
@@ -20,6 +20,9 @@ from ..geninfo.parser import parse_geninfo_from_prompt
from ..workflows import classify_workflow, parse_workflow, workflow_node_text
from .dimension_resolver import get_file_info as dims_get_file_info
from .dimension_resolver import normalize_dimensions
from .extractor_registry import (
EXIF_TEXT_IMAGE_EXTS,
)
from .extractor_registry import (
batch_tool_data as registry_batch_tool_data,
)
@@ -39,7 +42,6 @@ from .extractor_registry import (
expand_video_resolution_fields as registry_expand_video_resolution_fields,
)
from .extractor_registry import (
EXIF_TEXT_IMAGE_EXTS,
extract_image_by_extension as registry_extract_image_by_extension,
)
from .extractor_registry import (
@@ -76,6 +78,7 @@ from .retry_coordinator import (
log_metadata_issue as retry_log_metadata_issue,
)
from .section_catalog import PARSER_FAMILY_VERSION
from .sidecar import apply_sidecar_fallback
logger = get_logger(__name__)
_METADATA_TRANSIENT_RETRY_ATTEMPTS = 3
@@ -438,9 +441,19 @@ class MetadataService:
def _is_transient_metadata_read_error(self, result: Result[dict[str, Any]], file_path: str) -> bool:
return retry_is_transient_metadata_read_error(result, file_path, _TRANSIENT_ERROR_HINTS)
async def _apply_sidecar_fallback(self, combined: dict[str, Any]) -> None:
"""Fill a missing workflow/prompt from ``<file>.json`` / ``<stem>.json``."""
try:
await asyncio.to_thread(apply_sidecar_fallback, combined)
except Exception:
logger.debug("_apply_sidecar_fallback: suppressed exception", exc_info=True)
async def _enrich_with_geninfo_async(self, combined: dict[str, Any]) -> None:
"""Helper to parse geninfo from prompt/workflow in combined metadata (Worker Thread)."""
combined.setdefault("metadata_parser_version", PARSER_FAMILY_VERSION)
# Sidecar JSON before geninfo parsing so a JPEG/MP4 whose saver wrote
# the workflow next to it still gets a workflow, prompt and geninfo.
await self._apply_sidecar_fallback(combined)
override = build_geninfo_override(combined)
prompt_graph = combined.get("prompt")
@@ -1042,6 +1055,7 @@ class MetadataService:
metadata_result: Result[dict[str, Any]],
) -> Result[dict[str, Any]]:
combined.setdefault("metadata_parser_version", PARSER_FAMILY_VERSION)
await self._apply_sidecar_fallback(combined)
if registry_should_parse_geninfo(combined):
await self._enrich_with_geninfo_async(combined)
elif "geninfo" not in combined:
+154
View File
@@ -0,0 +1,154 @@
"""Sidecar JSON files that carry ComfyUI metadata for a media file.
Many save nodes and external tools write the generation data next to the file
instead of (or in addition to) embedding it, typically for formats that cannot
hold it (JPEG/MP4 from some savers, OBJ/PLY/STL meshes) or when embedding is
disabled. Two naming conventions are recognised, in this order:
* ``<file>.<ext>.json`` -- e.g. ``image.png.json``, ``mesh.obj.json``
* ``<stem>.json`` -- e.g. ``image.json`` next to ``image.png``
The JSON may be a UI workflow, an API prompt graph, or an object wrapping
them (``{"workflow": ..., "prompt": ...}``, values may be JSON strings), plus
optional ``parameters`` / ``geninfo`` / ``generation_time_ms``. Sidecars are
only a fallback: embedded metadata always wins.
"""
from __future__ import annotations
import json
import os
from typing import Any
from ...shared import get_logger
from .parsing_utils import (
looks_like_comfyui_prompt_graph,
looks_like_comfyui_workflow,
try_parse_json_text,
)
logger = get_logger(__name__)
MAX_SIDECAR_FILE_BYTES = 10 * 1024 * 1024 # 10 MB
SIDECAR_EXTRA_KEYS = ("parameters", "geninfo", "generation_time_ms")
def sidecar_candidates(file_path: str) -> list[str]:
"""Candidate sidecar paths for ``file_path``, most specific first."""
path = str(file_path or "")
if not path:
return []
out = [path + ".json"]
stem, ext = os.path.splitext(path)
if ext and ext.lower() != ".json":
out.append(stem + ".json")
return out
def find_sidecar_json(file_path: str) -> str | None:
for candidate in sidecar_candidates(file_path):
try:
if os.path.isfile(candidate):
return candidate
except OSError:
continue
return None
def read_sidecar_json(sidecar_path: str) -> dict[str, Any] | None:
"""Read a sidecar as a JSON object (size-capped, UTF-8 with or without BOM)."""
try:
size = os.path.getsize(sidecar_path)
if size > MAX_SIDECAR_FILE_BYTES:
logger.warning("Sidecar JSON too large (%s bytes): %s", size, sidecar_path)
return None
with open(sidecar_path, encoding="utf-8-sig") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else None
except (OSError, ValueError, UnicodeDecodeError) as exc:
logger.debug("Failed to read sidecar JSON %s: %s", sidecar_path, exc)
return None
def _typed_field(raw: Any, predicate: Any) -> Any | None:
if isinstance(raw, dict) and predicate(raw):
return raw
if isinstance(raw, str):
parsed = try_parse_json_text(raw)
if parsed and predicate(parsed):
return parsed
return None
def classify_sidecar_data(data: dict[str, Any]) -> dict[str, Any]:
"""Split sidecar content into ``workflow`` / ``prompt`` (+ known extras)."""
result: dict[str, Any] = {
"workflow": _typed_field(data.get("workflow"), looks_like_comfyui_workflow),
"prompt": _typed_field(data.get("prompt"), looks_like_comfyui_prompt_graph),
}
if not result["workflow"] and not result["prompt"]:
if looks_like_comfyui_prompt_graph(data):
result["prompt"] = data
elif looks_like_comfyui_workflow(data):
result["workflow"] = data
for key in SIDECAR_EXTRA_KEYS:
if key in data:
result[key] = data[key]
return result
def load_sidecar_metadata(file_path: str) -> dict[str, Any] | None:
"""Classified sidecar data for ``file_path`` with ``sidecar_path`` set, or None."""
sidecar_path = find_sidecar_json(file_path)
if not sidecar_path:
return None
data = read_sidecar_json(sidecar_path)
if not data:
return None
classified = classify_sidecar_data(data)
if not (classified.get("workflow") or classified.get("prompt") or any(
classified.get(key) is not None for key in SIDECAR_EXTRA_KEYS
)):
return None
classified["sidecar_path"] = sidecar_path
return classified
def apply_sidecar_fallback(combined: dict[str, Any], file_path: str | None = None) -> bool:
"""Fill missing workflow/prompt/extras of ``combined`` from a sidecar.
Embedded values are never overwritten. Returns True when anything was added.
"""
if not isinstance(combined, dict):
return False
if combined.get("workflow") and combined.get("prompt"):
return False
path = file_path
if not path:
info = combined.get("file_info")
path = info.get("filepath") if isinstance(info, dict) else None
if not path:
return False
sidecar = load_sidecar_metadata(str(path))
if not sidecar:
return False
added = False
for key in ("workflow", "prompt", *SIDECAR_EXTRA_KEYS):
value = sidecar.get(key)
if value is not None and not combined.get(key):
combined[key] = value
added = True
if added:
combined["sidecar_path"] = sidecar["sidecar_path"]
return added
__all__ = [
"MAX_SIDECAR_FILE_BYTES",
"apply_sidecar_fallback",
"classify_sidecar_data",
"find_sidecar_json",
"load_sidecar_metadata",
"read_sidecar_json",
"sidecar_candidates",
]
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import Any
@@ -12,6 +13,7 @@ from ...adapters.comfy_core import (
get_prompt_output_files,
get_temp_directory,
get_workflow_id_for_prompt,
has_prompt_history_entry,
send_event,
)
from ...adapters.core_assets import fetch_by_job_id
@@ -22,6 +24,24 @@ from ..index.metadata_helpers import MetadataHelpers
logger = get_logger(__name__)
# ComfyUI calls cache providers' ``on_prompt_end`` from the ``finally`` of
# ``PromptExecutor.execute()``, i.e. *before* the prompt worker stores the
# result with ``PromptQueue.task_done()`` (see ComfyUI ``main.py``). Ingestion
# scheduled from that hook can therefore read history before the entry exists
# and silently index nothing. Wait for the entry with a short backoff first.
_HISTORY_WAIT_DELAYS_S: tuple[float, ...] = (0.05, 0.1, 0.2, 0.4, 0.8, 1.6, 3.2)
async def _wait_for_history_entry(prompt_id: str) -> bool:
if has_prompt_history_entry(prompt_id):
return True
for delay in _HISTORY_WAIT_DELAYS_S:
await asyncio.sleep(delay)
if has_prompt_history_entry(prompt_id):
return True
logger.debug("History entry for prompt_id=%s not available after waiting", prompt_id)
return False
def _existing_refs(refs: list[PromptOutputFile]) -> list[tuple[Path, PromptOutputFile]]:
out: list[tuple[Path, PromptOutputFile]] = []
@@ -90,6 +110,7 @@ async def ingest_prompt_outputs(index_service: Any, prompt_id: str) -> Result[di
if not safe_prompt_id:
return Result.Err("INVALID_INPUT", "prompt_id is required")
await _wait_for_history_entry(safe_prompt_id)
refs = _existing_refs(await _collect_prompt_output_files(safe_prompt_id))
if not refs:
payload = {"prompt_id": safe_prompt_id, "indexed": 0, "paths": []}
+84 -11
View File
@@ -27,6 +27,7 @@ from mjr_am_backend.config import FFPROBE_BIN, OUTPUT_ROOT, get_runtime_index_db
from mjr_am_shared import Result, get_logger
from mjr_am_shared.runtime_env import get_env
from ..metadata.parsing_utils import looks_like_comfyui_prompt_graph, looks_like_comfyui_workflow
from .classifier import classify_workflow
from .parser import parse_workflow, workflow_node_text
@@ -43,7 +44,7 @@ WORKFLOW_VIDEO_THUMBNAIL_SECONDS = 5
WORKFLOW_MANAGED_DIRNAME = "workflows"
WORKFLOW_HISTORY_DIRNAME = ".history"
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
WORKFLOW_INDEX_VERSION = 4
WORKFLOW_INDEX_VERSION = 5 # 5: skip hidden dirs, validate JSON shape, workflow_format
def _env_path(raw: Any = "") -> Path | None:
@@ -252,13 +253,83 @@ def _atomic_write_json(path: Path, payload: dict[str, Any]) -> Result[bool]:
return Result.Err("WORKFLOW_WRITE_FAILED", f"Failed to write workflow: {exc.__class__.__name__}")
_UI_WORKFLOW_MARKER_KEYS = ("links", "last_node_id", "last_link_id", "version", "extra", "definitions", "groups", "state")
_SKIPPED_WORKFLOW_DIRS = frozenset({"__pycache__", "node_modules"})
def workflow_json_format(data: Any) -> str:
"""``"ui"`` (graph export), ``"api"`` (prompt graph) or ``""`` (not a workflow).
Any ``.json`` under a workflow root used to become a workflow card, so
settings files, node configs and arbitrary JSON showed up in the
Workflow tab and failed to load.
"""
if not isinstance(data, dict):
return ""
if "nodes" not in data:
# {"workflow": {...}} / {"prompt": {...}} wrappers (prompt may be a JSON string).
wrapped = data.get("workflow")
if isinstance(wrapped, dict) and isinstance(wrapped.get("nodes"), list) and workflow_json_format(wrapped) == "ui":
return "ui"
if _api_prompt_of(data) is not data and _api_prompt_of(data):
return "api"
nodes = data.get("nodes")
if isinstance(nodes, list):
if nodes and not looks_like_comfyui_workflow(data):
return ""
# An empty graph (new workflow) is still a workflow export.
return "ui" if (nodes or any(key in data for key in _UI_WORKFLOW_MARKER_KEYS)) else ""
if looks_like_comfyui_prompt_graph(data):
return "api"
return ""
def _api_prompt_of(data: Any) -> dict[str, Any]:
"""The API prompt graph held by ``data`` (itself or its ``prompt`` field)."""
if not isinstance(data, dict):
return {}
prompt = data.get("prompt")
if isinstance(prompt, str):
try:
prompt = json.loads(prompt)
except ValueError:
prompt = None
if isinstance(prompt, dict) and looks_like_comfyui_prompt_graph(prompt):
return prompt
if looks_like_comfyui_prompt_graph(data):
return data
return {}
def _iter_workflow_json_files(root: Path):
"""``*.json`` under ``root``, skipping hidden entries.
Hidden directories hold Majoor's own version snapshots
(``.history/<name>/<stamp>.json``) and tool state; ``rglob`` listed every
snapshot as a separate workflow.
"""
try:
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [
d for d in dirnames if not d.startswith(".") and d not in _SKIPPED_WORKFLOW_DIRS
]
for name in filenames:
if name.startswith(".") or not name.lower().endswith(".json"):
continue
yield Path(dirpath) / name
except OSError:
return
def _safe_read_workflow_json(path: Path) -> dict[str, Any] | None:
try:
if path.stat().st_size > MAX_WORKFLOW_JSON_BYTES:
return None
with path.open("r", encoding="utf-8") as handle:
# utf-8-sig: workflows saved by Windows editors often start with a BOM,
# which plain utf-8 rejects (the file then silently vanished).
with path.open("r", encoding="utf-8-sig") as handle:
data = json.load(handle)
return data if isinstance(data, dict) else None
return data if workflow_json_format(data) else None
except Exception:
return None
@@ -1977,6 +2048,7 @@ def _workflow_to_card(path: Path, root: Path, *, use_library_meta: bool = True)
except Exception:
return None
parsed = parse_workflow(workflow)
workflow_format = workflow_json_format(workflow)
wf_hash = _workflow_hash(path)
subfolder = _workflow_subfolder(path, root)
display_name = str(workflow.get("name") or workflow.get("title") or path.stem).strip()
@@ -2014,7 +2086,10 @@ def _workflow_to_card(path: Path, root: Path, *, use_library_meta: bool = True)
"provider": detection["provider"],
"runs_on": detection["runs_on"],
**detection,
"node_count": parsed.node_count + parsed.subgraph_node_count,
"workflow_format": workflow_format,
"node_count": (
len(_api_prompt_of(workflow)) if workflow_format == "api" else parsed.node_count + parsed.subgraph_node_count
),
"link_count": parsed.link_count,
"subgraph_count": parsed.subgraph_count,
"missing_nodes": missing_nodes,
@@ -2188,7 +2263,9 @@ def _refresh_workflow_index_path(
use_library_meta=not (force_full_refresh or needs_legacy_hydration),
)
if card is None:
return filepath, False
# Not (or no longer) a workflow: leave it out of `seen` so a row from
# an earlier, laxer scan is removed as stale.
return None, False
updates = None
if force_full_refresh or needs_legacy_hydration:
updates = {
@@ -2212,11 +2289,7 @@ def _scan_workflow_index_roots(
indexed = 0
updated = 0
for root in scan_roots:
try:
paths = root.rglob("*.json")
except Exception:
continue
for path in paths:
for path in _iter_workflow_json_files(root):
if indexed >= MAX_WORKFLOW_FILES:
break
filepath, did_update = _refresh_workflow_index_path(
@@ -2421,7 +2494,7 @@ def _list_workflows_from_filesystem(
safe_subfolder = str(subfolder or "").strip().replace("\\", "/")
try:
for root in roots:
for path in root.rglob("*.json"):
for path in _iter_workflow_json_files(root):
if len(cards) >= MAX_WORKFLOW_FILES:
break
try:
+6 -5
View File
@@ -9,6 +9,7 @@ from uuid import uuid4
from aiohttp import web
from mjr_am_shared.runtime_env import get_env
from .request_keys import REQUEST_DURATION_MS_KEY, REQUEST_ID_KEY, REQUEST_STATS_KEY
from .shared import get_logger, request_id_var
from .utils import env_float
@@ -171,7 +172,7 @@ async def request_context_middleware(request: web.Request, handler):
return await handler(request)
rid = _get_request_id(request)
request["mjr_request_id"] = rid
request[REQUEST_ID_KEY] = rid
token = _set_request_id_context(rid)
start = time.perf_counter()
status: int | None = None
@@ -202,7 +203,7 @@ async def request_context_middleware(request: web.Request, handler):
raise
finally:
duration_ms = (time.perf_counter() - start) * 1000.0
request["mjr_duration_ms"] = duration_ms
request[REQUEST_DURATION_MS_KEY] = duration_ms
_reset_request_id_context(token)
_emit_request_log(request, status=status, duration_ms=duration_ms, error=error, error_type=error_type)
@@ -283,14 +284,14 @@ def _sanitize_query_params(request: web.Request) -> dict[str, Any]:
def build_request_log_fields(request: web.Request, response_status: int | None = None) -> dict[str, Any]:
stats = request.get("mjr_stats")
stats = request.get(REQUEST_STATS_KEY)
return {
"request_id": request.get("mjr_request_id"),
"request_id": request.get(REQUEST_ID_KEY),
"method": request.method,
"path": request.path,
"query": _sanitize_query_params(request),
"status": response_status,
"duration_ms": request.get("mjr_duration_ms"),
"duration_ms": request.get(REQUEST_DURATION_MS_KEY),
"remote": getattr(request, "remote", None),
"stats": stats if isinstance(stats, dict) else None,
}
+37
View File
@@ -0,0 +1,37 @@
"""Typed per-request storage keys shared by Majoor middlewares and handlers.
aiohttp >= 3.13 introduces ``web.RequestKey`` and emits ``NotAppKeyWarning``
for plain-string request storage keys. ComfyUI only pins ``aiohttp>=3.11``, so
we fall back to the historical string keys on older runtimes. Every reader and
writer must go through these constants so both spellings stay consistent.
"""
from __future__ import annotations
from typing import Any
from aiohttp import web
_RequestKey: Any = getattr(web, "RequestKey", None)
def _key(name: str, value_type: Any) -> Any:
if _RequestKey is None:
return name
try:
return _RequestKey(name, value_type)
except Exception:
return name
REQUEST_USER_ID_KEY: Any = _key("mjr_user_id", str)
REQUEST_ID_KEY: Any = _key("mjr_request_id", str)
REQUEST_DURATION_MS_KEY: Any = _key("mjr_duration_ms", float)
REQUEST_STATS_KEY: Any = _key("mjr_stats", dict)
__all__ = [
"REQUEST_DURATION_MS_KEY",
"REQUEST_ID_KEY",
"REQUEST_STATS_KEY",
"REQUEST_USER_ID_KEY",
]
@@ -14,6 +14,7 @@ from typing import Any
from aiohttp import web
from mjr_am_backend.shared import ErrorCode, Result, get_logger
from mjr_am_shared.runtime_env import get_env
logger = get_logger(__name__)
DEFAULT_MAX_JSON_BYTES = 10 * 1024 * 1024 # 10MB
@@ -6,6 +6,7 @@ from contextvars import ContextVar, Token
from typing import Any
from aiohttp import web
from mjr_am_backend.request_keys import REQUEST_USER_ID_KEY
from mjr_am_backend.shared import Result, get_logger
logger = get_logger(__name__)
@@ -115,7 +116,7 @@ def _get_request_user_id(request: web.Request | None) -> str:
if request is None:
return ""
try:
stored = str(request.get("mjr_user_id") or "").strip()
stored = str(request.get(REQUEST_USER_ID_KEY) or "").strip()
if stored:
return stored
except Exception:
+70 -28
View File
@@ -1,19 +1,23 @@
"""
``/api/v2/assets/*`` — OpenAPI-aligned read-only compat layer (Phase 3.1).
``/mjr/am/compat/assets/*`` — OpenAPI-aligned read-only compat layer (Phase 3.1).
Formerly served at ``/api/v2/assets/*``; that path sits in ComfyUI core's
``/api`` namespace and is now only an opt-in alias
(``MJR_AM_LEGACY_API_V2_ASSETS=1``).
This module exposes a small, schema-stable REST surface that mirrors the
ComfyUI core ``/api/assets/*`` endpoints defined in ``openapi.yaml`` (the
official spec used by Cloud and Desktop clients). We use the ``v2`` prefix
to avoid colliding with the core's own ``/api/assets/*`` routes when the
plugin is loaded alongside an asset-aware ComfyUI build.
official spec used by Cloud and Desktop clients). It lives under the
extension's own ``/mjr/am/`` prefix so it never collides with the core's
``/api/assets/*`` routes (or any future versioned core route).
Scope of this first cut (read-only, additive):
* ``HEAD /api/v2/assets/hash/{hash}`` — existence check by content hash
* ``GET /api/v2/assets`` — paginated list with basic filters
* ``GET /api/v2/assets/{id}`` — single asset metadata
* ``GET /api/v2/assets/{id}/content`` — binary file stream
* ``GET /api/v2/assets/{id}/tags`` — tag list for one asset
* ``HEAD <prefix>/hash/{hash}`` — existence check by content hash
* ``GET <prefix>`` — paginated list with basic filters
* ``GET <prefix>/{id}`` — single asset metadata
* ``GET <prefix>/{id}/content`` — binary file stream
* ``GET <prefix>/{id}/tags`` — tag list for one asset
Mutations (POST/PUT/PATCH/DELETE) are intentionally *not* implemented yet:
they would duplicate complex side-effecting flows already exposed under
@@ -468,7 +472,7 @@ async def _list_assets(request: web.Request) -> web.Response:
order=order,
)
except Exception as exc:
logger.warning("api/v2/assets list failed: %s", exc, exc_info=True)
logger.warning("compat assets list failed: %s", exc, exc_info=True)
return _api_error_response(Result.Err("DB_ERROR", "Asset listing failed"))
assets = [_row_to_asset(row) for row in rows]
@@ -505,7 +509,7 @@ async def _check_asset_by_hash(request: web.Request) -> web.Response:
(hex_part, f"blake3:{hex_part}"),
)
except Exception as exc:
logger.warning("api/v2/assets hash check failed: %s", exc, exc_info=True)
logger.warning("compat assets hash check failed: %s", exc, exc_info=True)
return web.Response(status=500)
found = bool(res.ok and res.data)
@@ -524,7 +528,7 @@ async def _get_asset(request: web.Request) -> web.Response:
try:
row = await _query_one(services["db"], where, params)
except Exception as exc:
logger.warning("api/v2/assets get failed: %s", exc, exc_info=True)
logger.warning("compat assets get failed: %s", exc, exc_info=True)
return _api_error_response(Result.Err("DB_ERROR", "Asset lookup failed"))
if row is None:
@@ -542,7 +546,7 @@ async def _get_asset_content(request: web.Request) -> web.StreamResponse:
try:
row = await _query_one(services["db"], where, params)
except Exception as exc:
logger.warning("api/v2/assets content lookup failed: %s", exc, exc_info=True)
logger.warning("compat assets content lookup failed: %s", exc, exc_info=True)
return web.json_response({"error": "Asset lookup failed"}, status=500)
if row is None:
return web.json_response({"error": "Asset not found"}, status=404)
@@ -573,7 +577,7 @@ async def _get_asset_tags(request: web.Request) -> web.Response:
try:
row = await _query_one(services["db"], where, params)
except Exception as exc:
logger.warning("api/v2/assets tags lookup failed: %s", exc, exc_info=True)
logger.warning("compat assets tags lookup failed: %s", exc, exc_info=True)
return _api_error_response(Result.Err("DB_ERROR", "Asset lookup failed"))
if row is None:
@@ -582,7 +586,7 @@ async def _get_asset_tags(request: web.Request) -> web.Response:
async def _seed_status(request: web.Request) -> web.Response:
"""Compat shim for ``GET /api/v2/assets/seed/status``.
"""Compat shim for ``GET <prefix>/seed/status``.
We do not run a background "seeder" the way ComfyUI core does; our
ingestion pipeline is the scanner + watcher loop. This endpoint reports
@@ -604,7 +608,7 @@ async def _seed_status(request: web.Request) -> web.Response:
"GROUP BY enrichment_level"
)
except Exception as exc:
logger.warning("api/v2/assets seed/status failed: %s", exc, exc_info=True)
logger.warning("compat assets seed/status failed: %s", exc, exc_info=True)
return _api_error_response(Result.Err("DB_ERROR", "Seed status lookup failed"))
total = 0
@@ -639,9 +643,9 @@ async def _seed_status(request: web.Request) -> web.Response:
async def _tags_refine(request: web.Request) -> web.Response:
"""``GET /api/v2/assets/tags/refine`` — tag histogram for filtered subset.
"""``GET <prefix>/tags/refine`` — tag histogram for filtered subset.
Accepts the same filters as ``GET /api/v2/assets`` (``name_contains``,
Accepts the same filters as ``GET <prefix>`` (``name_contains``,
``include_tags``, ``exclude_tags``) and returns the most-frequent tags
among matching assets. Results are read from the normalized
``asset_tags`` + ``tags`` tables.
@@ -692,7 +696,7 @@ async def _tags_refine(request: web.Request) -> web.Response:
try:
res = await db.aquery(sql, tuple([*params, limit]))
except Exception as exc:
logger.warning("api/v2/assets tags/refine failed: %s", exc, exc_info=True)
logger.warning("compat assets tags/refine failed: %s", exc, exc_info=True)
return _api_error_response(Result.Err("DB_ERROR", "Tag refine failed"))
tags: list[dict[str, Any]] = []
@@ -711,14 +715,52 @@ async def _tags_refine(request: web.Request) -> web.Response:
# --------------------------------------------------------------------------- #
def register_api_v2_asset_routes(routes: web.RouteTableDef) -> None:
"""Register the ``/api/v2/assets/*`` compat surface."""
routes.head("/api/v2/assets/hash/{hash}")(_check_asset_by_hash)
#: Canonical prefix. Lives under the extension's own ``/mjr/am/`` namespace so
#: it can never be shadowed by (or shadow) ComfyUI core routes.
COMPAT_ASSETS_PREFIX = "/mjr/am/compat/assets"
#: Historical prefix. ``/api/...`` belongs to ComfyUI core: if core ever ships
#: ``/api/v2/assets`` its routes (registered first) would silently win. Kept
#: only as an opt-in alias for external clients that still call it.
LEGACY_COMPAT_ASSETS_PREFIX = "/api/v2/assets"
LEGACY_ALIAS_ENV = "MJR_AM_LEGACY_API_V2_ASSETS"
def legacy_alias_enabled() -> bool:
from mjr_am_backend.utils import env_bool
return env_bool(LEGACY_ALIAS_ENV, False)
def _register_compat_asset_routes(routes: web.RouteTableDef, prefix: str) -> None:
routes.head(prefix + "/hash/{hash}")(_check_asset_by_hash)
# Static-path routes MUST be declared before the catch-all ``/{id}``
# variants so aiohttp doesn't route ``seed`` / ``tags`` to ``_get_asset``.
routes.get("/api/v2/assets/seed/status")(_seed_status)
routes.get("/api/v2/assets/tags/refine")(_tags_refine)
routes.get("/api/v2/assets")(_list_assets)
routes.get("/api/v2/assets/{id}")(_get_asset)
routes.get("/api/v2/assets/{id}/content")(_get_asset_content)
routes.get("/api/v2/assets/{id}/tags")(_get_asset_tags)
routes.get(prefix + "/seed/status")(_seed_status)
routes.get(prefix + "/tags/refine")(_tags_refine)
routes.get(prefix)(_list_assets)
routes.get(prefix + "/{id}")(_get_asset)
routes.get(prefix + "/{id}/content")(_get_asset_content)
routes.get(prefix + "/{id}/tags")(_get_asset_tags)
def register_api_v2_asset_routes(
routes: web.RouteTableDef, *, include_legacy_alias: bool | None = None
) -> None:
"""Register the OpenAPI-aligned compat surface.
Always served under ``/mjr/am/compat/assets/*``. The legacy
``/api/v2/assets/*`` alias is registered only when
``MJR_AM_LEGACY_API_V2_ASSETS=1`` (or ``include_legacy_alias=True``).
"""
_register_compat_asset_routes(routes, COMPAT_ASSETS_PREFIX)
if include_legacy_alias is None:
include_legacy_alias = legacy_alias_enabled()
if include_legacy_alias:
logger.warning(
"Majoor: legacy /api/v2/assets alias enabled via %s; it is deprecated "
"and may be shadowed by ComfyUI core. Use %s instead.",
LEGACY_ALIAS_ENV,
COMPAT_ASSETS_PREFIX,
)
_register_compat_asset_routes(routes, LEGACY_COMPAT_ASSETS_PREFIX)
+4 -3
View File
@@ -43,6 +43,7 @@ from mjr_am_backend.features.watcher_settings import ( # noqa: F401 — exposed
get_watcher_settings,
update_watcher_settings,
)
from mjr_am_backend.request_keys import REQUEST_STATS_KEY
from mjr_am_backend.shared import Result, get_logger, sanitize_error_message
from mjr_am_backend.utils import parse_bool, sanitize_for_json
@@ -373,7 +374,7 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
merged = all_scope_result.data or {}
try:
if isinstance(merged, dict):
request["mjr_stats"] = {
request[REQUEST_STATS_KEY] = {
"scanned": merged.get("scanned", 0),
"added": merged.get("added", 0),
"updated": merged.get("updated", 0),
@@ -472,7 +473,7 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
return _json_response(Result.Err("TIMEOUT", "Scan timed out"))
try:
if result.ok and isinstance(result.data, dict):
request["mjr_stats"] = {
request[REQUEST_STATS_KEY] = {
"scanned": result.data.get("scanned", 0),
"added": result.data.get("added", 0),
"updated": result.data.get("updated", 0),
@@ -1246,7 +1247,7 @@ def register_scan_routes(routes: web.RouteTableDef) -> None:
try:
request["mjr_stats"] = {
request[REQUEST_STATS_KEY] = {
"scanned": total_stats.get("scanned", 0),
"added": total_stats.get("added", 0),
"updated": total_stats.get("updated", 0),
+37 -2
View File
@@ -133,6 +133,31 @@ def _filter_recent_generated_files(filepaths: list[Any] | None) -> list[Any]:
return [f for f in (filepaths or []) if f]
def build_sidecar_reindex_callback(index_service: Any):
"""Force re-extraction of media whose sidecar JSON appeared or changed.
``incremental=False``: the media file itself is unchanged, so an
incremental pass would skip it and never read the new sidecar.
"""
async def reindex_callback(filepaths, base_dir, source=None, root_id=None):
paths = [Path(f) for f in (filepaths or []) if f]
if not paths:
return
invalidate = getattr(index_service, "invalidate_cached_metadata", None)
if callable(invalidate):
await invalidate([str(p) for p in paths])
await index_service.index_paths(
paths=paths,
base_dir=base_dir,
incremental=False,
source=source or "watcher",
root_id=root_id,
)
return reindex_callback
def _build_watcher_callbacks(index_service: Any):
async def index_callback(filepaths, base_dir, source=None, root_id=None):
if not filepaths:
@@ -195,7 +220,12 @@ async def _start_watcher_for_scope(svc: dict[str, Any], index_service: Any, *, _
if _build_watch_paths is None:
_build_watch_paths = build_watch_paths
index_callback, remove_callback, move_callback = _build_watcher_callbacks(index_service)
new_watcher = OutputWatcher(index_callback, remove_callback=remove_callback, move_callback=move_callback)
new_watcher = OutputWatcher(
index_callback,
remove_callback=remove_callback,
move_callback=move_callback,
reindex_callback=build_sidecar_reindex_callback(index_service),
)
desired_scope, desired_root_id = _watcher_scope_config(svc)
request_user_id = _current_request_user_id()
watch_paths = _build_watch_paths_for_context(_build_watch_paths, desired_scope, desired_root_id, request_user_id)
@@ -577,7 +607,12 @@ def register_watcher_routes(routes: web.RouteTableDef, *, deps: dict | None = No
# Use the shared callback builder so all watcher instances share the same
# recent-generated filtering logic (BUG-02: was previously duplicated inline).
index_cb, remove_cb, move_cb = _build_watcher_callbacks(index_service)
new_watcher = OutputWatcher(index_cb, remove_callback=remove_cb, move_callback=move_cb)
new_watcher = OutputWatcher(
index_cb,
remove_callback=remove_cb,
move_callback=move_cb,
reindex_callback=build_sidecar_reindex_callback(index_service),
)
loop = asyncio.get_running_loop()
await new_watcher.start(watch_paths, loop)
svc["watcher"] = new_watcher
+4 -1
View File
@@ -16,7 +16,10 @@ from aiohttp import web
from mjr_am_backend.adapters.comfy_core import get_input_directory
from mjr_am_backend.config import get_runtime_output_root
from mjr_am_backend.custom_roots import list_custom_roots, resolve_custom_root
from mjr_am_backend.features.metadata.thumbnail_cache import PREVIEW_ONLY_EXTENSIONS, get_or_create_preview
from mjr_am_backend.features.metadata.thumbnail_cache import (
PREVIEW_ONLY_EXTENSIONS,
get_or_create_preview,
)
from mjr_am_backend.features.viewer.info import build_viewer_media_info
from mjr_am_backend.shared import Result, get_logger
@@ -6,6 +6,7 @@ from collections.abc import Awaitable, Callable
from typing import Any
from aiohttp import web
from mjr_am_backend.request_keys import REQUEST_USER_ID_KEY
from mjr_am_backend.shared import Result, get_logger
from .core import (
@@ -20,9 +21,10 @@ logger = get_logger(__name__)
API_PREFIX = "/mjr/am/"
# The OpenAPI-aligned read-only compat surface (routes/handlers/api_v2_assets.py).
# It is part of this extension's HTTP surface and must sit behind the same
# security boundary as API_PREFIX.
# Legacy, opt-in alias of the OpenAPI-aligned compat surface
# (routes/handlers/api_v2_assets.py; canonical path /mjr/am/compat/assets is
# already covered by API_PREFIX). When enabled it is part of this extension's
# HTTP surface and must sit behind the same security boundary as API_PREFIX.
COMPAT_API_PREFIX = "/api/v2/assets"
# ComfyUI's PromptServer mirrors EVERY registered route under an additional
@@ -178,7 +180,7 @@ def _requires_auth(path: str, method: str) -> bool:
def _store_request_user_id(request: web.Request, user_id: Any) -> None:
try:
request["mjr_user_id"] = str(user_id or "").strip()
request[REQUEST_USER_ID_KEY] = str(user_id or "").strip()
except Exception:
logger.debug("_store_request_user_id: suppressed exception", exc_info=True)
+5 -5
View File
@@ -129,11 +129,11 @@ OPTIONAL_ROUTE_REGISTRATIONS: tuple[RouteRegistration, ...] = (
"api v2 assets (compat)",
register_api_v2_asset_routes,
(
" HEAD /api/v2/assets/hash/{hash} (Added)",
" GET /api/v2/assets (Added)",
" GET /api/v2/assets/{id} (Added)",
" GET /api/v2/assets/{id}/content (Added)",
" GET /api/v2/assets/{id}/tags (Added)",
" HEAD /mjr/am/compat/assets/hash/{hash} (Added)",
" GET /mjr/am/compat/assets (Added)",
" GET /mjr/am/compat/assets/{id} (Added)",
" GET /mjr/am/compat/assets/{id}/content (Added)",
" GET /mjr/am/compat/assets/{id}/tags (Added)",
),
),
)
+3 -1
View File
@@ -6,8 +6,10 @@ from functools import lru_cache
from pathlib import Path
from typing import TypedDict
from .log import get_logger
from mjr_am_shared.runtime_env import get_env
from .log import get_logger
logger = get_logger(__name__)
+167 -176
View File
@@ -10,13 +10,12 @@
"pinia": "^4.0.3",
"primevue": "4.5.5",
"three": "0.186.1",
"vue": "^3.5.41"
"vue": "^3.5.44"
},
"devDependencies": {
"@comfyorg/comfyui-frontend-types": "^1.57.3",
"@comfyorg/litegraph": "^0.17.2",
"@comfyorg/comfyui-frontend-types": "^1.60.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.63.0",
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.4",
"@vitejs/plugin-vue": "^6.0.9",
"@vitest/coverage-v8": "^5.0.5",
@@ -24,7 +23,7 @@
"eslint": "^10.12.0",
"happy-dom": "^21.0.4",
"knip": "^6.39.0",
"oxlint": "^1.86.0",
"oxlint": "^1.90.0",
"prettier": "^3.9.9",
"typescript": "^7.0.2",
"vite": "^8.3.5",
@@ -32,9 +31,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"version": "7.29.11",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz",
"integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -50,12 +49,12 @@
}
},
"node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"version": "7.29.11",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz",
"integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
"@babel/types": "^7.29.11"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -65,12 +64,12 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"version": "7.29.11",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz",
"integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-string-parser": "^7.29.11",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
@@ -119,9 +118,9 @@
"license": "MIT"
},
"node_modules/@comfyorg/comfyui-frontend-types": {
"version": "1.57.3",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-frontend-types/-/comfyui-frontend-types-1.57.3.tgz",
"integrity": "sha512-QjsuFqO/9RHKPGDfW2eCobIOgU38IkIOtam+nZiStvmcdfIn5MS0NTB+pzAopev3nC+Eha7pWtRb3vRf3kuVkg==",
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-frontend-types/-/comfyui-frontend-types-1.60.0.tgz",
"integrity": "sha512-G37ThdoiwH1cJrCwkiFLokKcwRmheOwPm4l0EbofPDhfwLofj5mX9+D3y0DSkrGeXG3Bt3JDjaJCMSTaIdEyLQ==",
"dev": true,
"license": "GPL-3.0-only",
"dependencies": {
@@ -132,14 +131,6 @@
"zod": "^4.6.5"
}
},
"node_modules/@comfyorg/litegraph": {
"version": "0.17.2",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.17.2.tgz",
"integrity": "sha512-6O+PSu80Tycj4NkYyLv/r7tS5V1JDVnSZLUglXCwDTPDJbjqyyNWjSUuLTFpvNOMuK+iPYMmuOWazBcYx/Rd6w==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -1075,9 +1066,9 @@
]
},
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.86.0.tgz",
"integrity": "sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.90.0.tgz",
"integrity": "sha512-sgfalb1HuxYjiAa4wESZ626z1FGusqNcXYHyB5oIOEprS2sbPg0UYEfo83uF1GNG70DIfZxHkXuE6KFrlSdn8Q==",
"cpu": [
"arm"
],
@@ -1092,9 +1083,9 @@
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.86.0.tgz",
"integrity": "sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.90.0.tgz",
"integrity": "sha512-RcKhz1ETMq+fazE9TKS2+5NIRGwigm/nW8egqF0LI+A8Yw2iW5r0E/C3XAPRiwIm/vcPiutIRIa6IQCIKhZsTQ==",
"cpu": [
"arm64"
],
@@ -1109,9 +1100,9 @@
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.86.0.tgz",
"integrity": "sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.90.0.tgz",
"integrity": "sha512-PSrxMvP6mx+p1karZcUxLdy9lpaP+JWQPti4al9FgTaPhxE+pT/BjhGExloVVSWgZgIfOlLOjVyyq8BXlodhLQ==",
"cpu": [
"arm64"
],
@@ -1126,9 +1117,9 @@
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.86.0.tgz",
"integrity": "sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.90.0.tgz",
"integrity": "sha512-OLbU9qsrV2Q8QxT2OEzOPdO8zCCpyI8qB+hDI8yr6RsYELtkf0FAZhJdfjM/RnLHKzt44HWwAPnk92vBtKofvw==",
"cpu": [
"x64"
],
@@ -1143,9 +1134,9 @@
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.86.0.tgz",
"integrity": "sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.90.0.tgz",
"integrity": "sha512-fBA4pWyVDARmNucQqpyFuPC49xYf1r+sX7dxPL5fNPQrSMLum39Sn56tZ9JPleWC7FzLPwSQ7plYYfirYBHF8A==",
"cpu": [
"x64"
],
@@ -1160,9 +1151,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.86.0.tgz",
"integrity": "sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.90.0.tgz",
"integrity": "sha512-zhxH78eP2nR6/pGdhBc7MIfjhhcgATeu8fkmkFJbuzfk6Cj0FzBxyEhL85yDqrCybjx+G2YDzZGsxIO9kvO4Iw==",
"cpu": [
"arm"
],
@@ -1177,9 +1168,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.86.0.tgz",
"integrity": "sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.90.0.tgz",
"integrity": "sha512-MvYmpHyJqUJjEvkZbJHdwfdL45cGn8EVBkYmoXFiaYFW9suoD3dbJl5mBdpy0DVgdjlyddmldbRtk3cCgETadQ==",
"cpu": [
"arm"
],
@@ -1194,9 +1185,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.86.0.tgz",
"integrity": "sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.90.0.tgz",
"integrity": "sha512-L13Sy+Kg0nZhwTsToniqjUFPA2TuM6962qCZ5Wp5KeB517Tw7ApdLTTtlb+bF86fcZZa2EtvCVvAJxk4Dya/MQ==",
"cpu": [
"arm64"
],
@@ -1211,9 +1202,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.86.0.tgz",
"integrity": "sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.90.0.tgz",
"integrity": "sha512-wFDdZWGpzIPFdJFjSLWnKB0TGzFriTKUIrl04iMqMyM9BBIJTuerfCHYa0WpDSjr5x3lcW/CHLyacSt886WpGg==",
"cpu": [
"arm64"
],
@@ -1228,9 +1219,9 @@
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.86.0.tgz",
"integrity": "sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.90.0.tgz",
"integrity": "sha512-FRywfn+MoKn8xpBSzgNwNVliSKRI2QXkxi/iF7o6FEQs8Bh8z2RpVTaFAcO3pchXjvB2eGNFIoLLQABIQ+u7VA==",
"cpu": [
"ppc64"
],
@@ -1245,9 +1236,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.86.0.tgz",
"integrity": "sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.90.0.tgz",
"integrity": "sha512-IS2PaUgpc/qeLADTyhUNmdx3x4ijs9pjJdPKBYHaT+mAE7k3FsjnHPTjhpsak1b++h6CIPhv6WX9Z+y7Pw0voQ==",
"cpu": [
"riscv64"
],
@@ -1262,9 +1253,9 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.86.0.tgz",
"integrity": "sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.90.0.tgz",
"integrity": "sha512-s7JFKRkGS8O7i2ZVODfuO++NC7sH6HDqpMn8lYxw91w0SXJOxnAVjQ8utvmDwLMMT0jaSkuOKuUYJDGDtu4sHQ==",
"cpu": [
"riscv64"
],
@@ -1279,9 +1270,9 @@
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.86.0.tgz",
"integrity": "sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.90.0.tgz",
"integrity": "sha512-b0GxrOQr5VONCkd7KLSqmP6b6nqZuTpuS8QPQHzltmqdXP5bY0Jl0CZ0QmlCTrS3ExaLYbioFYOARzNbYvcApQ==",
"cpu": [
"s390x"
],
@@ -1296,9 +1287,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.86.0.tgz",
"integrity": "sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.90.0.tgz",
"integrity": "sha512-iaJeShJtgUp8rZbt9YUdTAGOpmX5KogCLba17Jp1tWdJ3F4M3D2g2TKUyI1FMTHxKH9NNHuQcuC2kZRg6IV7yQ==",
"cpu": [
"x64"
],
@@ -1313,9 +1304,9 @@
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.86.0.tgz",
"integrity": "sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.90.0.tgz",
"integrity": "sha512-/aZoXnL5WBLCXuzfHqvEFXcm4iwYmr/cYByHDTXzLFzJdE5DC7bm6phpWqHv6BeqpVgY9IpBAxGDv2WEkl5JCg==",
"cpu": [
"x64"
],
@@ -1330,9 +1321,9 @@
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.86.0.tgz",
"integrity": "sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.90.0.tgz",
"integrity": "sha512-SMhk7ozwoxYDKfYqGZCQEfULOKDneF9uhaoIf2neoDLWvNtjY9yVeL7YHmE5L9WdKvMpWNwwZxhenxBu6AgfOg==",
"cpu": [
"arm64"
],
@@ -1347,9 +1338,9 @@
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.86.0.tgz",
"integrity": "sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.90.0.tgz",
"integrity": "sha512-KBvR3A9fO7fCi2vCP8ifWvlia7tGEQV9NaYh+IGwa/7Groy48Mt/UaYqMK9/zOtAWl20BUCf6RPqzJ32fRw33Q==",
"cpu": [
"arm64"
],
@@ -1364,9 +1355,9 @@
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.86.0.tgz",
"integrity": "sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.90.0.tgz",
"integrity": "sha512-+x42rDHQGbTCuT3EzpWAzhP3NoVPE/cJsG4gnrakOSg0olV+GCdMpPU23Nvrl3zZMnXxFZdTtDv4D3SuaA5ZeQ==",
"cpu": [
"ia32"
],
@@ -1381,9 +1372,9 @@
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.86.0.tgz",
"integrity": "sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.90.0.tgz",
"integrity": "sha512-30JiaFEBUB9peEOcvVM8ggo88SoN/IdSr9DW505JdBji/R6dM66gKEiG65hw6qcaF2f4mO0cEJ+RwRZprmun9g==",
"cpu": [
"x64"
],
@@ -1398,13 +1389,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
@@ -2295,16 +2286,16 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz",
"integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz",
"integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/shared": "3.5.43",
"@babel/parser": "^7.29.9",
"@vue/shared": "3.5.44",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
}
},
"node_modules/@vue/compiler-core/node_modules/estree-walker": {
@@ -2314,30 +2305,30 @@
"license": "MIT"
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz",
"integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz",
"integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.43",
"@vue/shared": "3.5.43"
"@vue/compiler-core": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz",
"integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz",
"integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.8",
"@vue/compiler-core": "3.5.43",
"@vue/compiler-dom": "3.5.43",
"@vue/compiler-ssr": "3.5.43",
"@vue/shared": "3.5.43",
"@babel/parser": "^7.29.9",
"@vue/compiler-core": "3.5.44",
"@vue/compiler-dom": "3.5.44",
"@vue/compiler-ssr": "3.5.44",
"@vue/shared": "3.5.44",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.28",
"source-map-js": "^1.2.1"
"postcss": "^8.5.29",
"source-map-js": "^1.2.2"
}
},
"node_modules/@vue/compiler-sfc/node_modules/estree-walker": {
@@ -2347,13 +2338,13 @@
"license": "MIT"
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz",
"integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz",
"integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.43",
"@vue/shared": "3.5.43"
"@vue/compiler-dom": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/devtools-api": {
@@ -2387,51 +2378,51 @@
"peer": true
},
"node_modules/@vue/reactivity": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz",
"integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz",
"integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.43"
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz",
"integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz",
"integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.43",
"@vue/shared": "3.5.43"
"@vue/reactivity": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz",
"integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz",
"integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.43",
"@vue/runtime-core": "3.5.43",
"@vue/shared": "3.5.43",
"@vue/reactivity": "3.5.44",
"@vue/runtime-core": "3.5.44",
"@vue/shared": "3.5.44",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz",
"integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz",
"integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.43",
"@vue/runtime-dom": "3.5.43",
"@vue/shared": "3.5.43"
"@vue/compiler-ssr": "3.5.44",
"@vue/runtime-dom": "3.5.44",
"@vue/shared": "3.5.44"
}
},
"node_modules/@vue/shared": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz",
"integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz",
"integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==",
"license": "MIT"
},
"node_modules/@vue/test-utils": {
@@ -3785,9 +3776,9 @@
}
},
"node_modules/oxlint": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.86.0.tgz",
"integrity": "sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg==",
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.90.0.tgz",
"integrity": "sha512-RkTN9EmgIUpLuhNoLhO3OliMGJlvb3Dk7XVjBZnfoMCHrVpuz5ESd97klfiyYeV7XfA2lNLnDCPMkUI+Qw5How==",
"dev": true,
"license": "MIT",
"bin": {
@@ -3800,28 +3791,28 @@
"url": "https://github.com/sponsors/oxc-project"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.86.0",
"@oxlint/binding-android-arm64": "1.86.0",
"@oxlint/binding-darwin-arm64": "1.86.0",
"@oxlint/binding-darwin-x64": "1.86.0",
"@oxlint/binding-freebsd-x64": "1.86.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.86.0",
"@oxlint/binding-linux-arm-musleabihf": "1.86.0",
"@oxlint/binding-linux-arm64-gnu": "1.86.0",
"@oxlint/binding-linux-arm64-musl": "1.86.0",
"@oxlint/binding-linux-ppc64-gnu": "1.86.0",
"@oxlint/binding-linux-riscv64-gnu": "1.86.0",
"@oxlint/binding-linux-riscv64-musl": "1.86.0",
"@oxlint/binding-linux-s390x-gnu": "1.86.0",
"@oxlint/binding-linux-x64-gnu": "1.86.0",
"@oxlint/binding-linux-x64-musl": "1.86.0",
"@oxlint/binding-openharmony-arm64": "1.86.0",
"@oxlint/binding-win32-arm64-msvc": "1.86.0",
"@oxlint/binding-win32-ia32-msvc": "1.86.0",
"@oxlint/binding-win32-x64-msvc": "1.86.0"
"@oxlint/binding-android-arm-eabi": "1.90.0",
"@oxlint/binding-android-arm64": "1.90.0",
"@oxlint/binding-darwin-arm64": "1.90.0",
"@oxlint/binding-darwin-x64": "1.90.0",
"@oxlint/binding-freebsd-x64": "1.90.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.90.0",
"@oxlint/binding-linux-arm-musleabihf": "1.90.0",
"@oxlint/binding-linux-arm64-gnu": "1.90.0",
"@oxlint/binding-linux-arm64-musl": "1.90.0",
"@oxlint/binding-linux-ppc64-gnu": "1.90.0",
"@oxlint/binding-linux-riscv64-gnu": "1.90.0",
"@oxlint/binding-linux-riscv64-musl": "1.90.0",
"@oxlint/binding-linux-s390x-gnu": "1.90.0",
"@oxlint/binding-linux-x64-gnu": "1.90.0",
"@oxlint/binding-linux-x64-musl": "1.90.0",
"@oxlint/binding-openharmony-arm64": "1.90.0",
"@oxlint/binding-win32-arm64-msvc": "1.90.0",
"@oxlint/binding-win32-ia32-msvc": "1.90.0",
"@oxlint/binding-win32-x64-msvc": "1.90.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=7.0.2003",
"oxlint-tsgolint": ">=7.0.2005",
"vite-plus": "*"
},
"peerDependenciesMeta": {
@@ -3961,13 +3952,13 @@
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
@@ -3977,9 +3968,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4526,16 +4517,16 @@
}
},
"node_modules/vue": {
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz",
"integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==",
"version": "3.5.44",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz",
"integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.43",
"@vue/compiler-sfc": "3.5.43",
"@vue/runtime-dom": "3.5.43",
"@vue/server-renderer": "3.5.43",
"@vue/shared": "3.5.43"
"@vue/compiler-dom": "3.5.44",
"@vue/compiler-sfc": "3.5.44",
"@vue/runtime-dom": "3.5.44",
"@vue/server-renderer": "3.5.44",
"@vue/shared": "3.5.44"
},
"peerDependencies": {
"typescript": "*"
+4 -5
View File
@@ -36,10 +36,9 @@
"brace-expansion": "5.0.12"
},
"devDependencies": {
"@comfyorg/comfyui-frontend-types": "^1.57.3",
"@comfyorg/litegraph": "^0.17.2",
"@comfyorg/comfyui-frontend-types": "^1.60.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.63.0",
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.4",
"@vitejs/plugin-vue": "^6.0.9",
"@vitest/coverage-v8": "^5.0.5",
@@ -47,7 +46,7 @@
"eslint": "^10.12.0",
"happy-dom": "^21.0.4",
"knip": "^6.39.0",
"oxlint": "^1.86.0",
"oxlint": "^1.90.0",
"prettier": "^3.9.9",
"typescript": "^7.0.2",
"vite": "^8.3.5",
@@ -58,6 +57,6 @@
"pinia": "^4.0.3",
"primevue": "4.5.5",
"three": "0.186.1",
"vue": "^3.5.41"
"vue": "^3.5.44"
}
}
+3 -3
View File
@@ -2,7 +2,7 @@
name = "majoor-assetsmanager"
description = "Majoor Assets Manager is an advanced asset browser for ComfyUI that provides a comprehensive solution for managing, organizing, and viewing your generated assets. It integrates directly into ComfyUI, offering features like full-text search, metadata extraction, rating and tagging systems, and advanced viewing capabilities."
version = "2.5.3"
requires-python = ">=3.10, <3.14"
requires-python = ">=3.10, <3.15"
license = {file = "LICENSE"}
authors = [
{ name = "MajoorWaldi" },
@@ -28,7 +28,7 @@ dependencies = [
"aiohttp>=3.8.0,<4.0.0",
"aiosqlite>=0.20.0,<1.0.0",
"numpy>=1.24,<3.0.0",
"pillow>=10.0.0,<13.0.0",
"pillow>=13.0.0,<14.0.0",
"av>=12.0.0,<18.0.0",
"hachoir>=3.3.0,<4.0.0",
"send2trash>=1.8.0,<3.0.0",
@@ -96,7 +96,7 @@ includes = ["dist/", "locales/", "example_workflows/"]
[tool.black]
line-length = 100
target-version = ["py310", "py311", "py312", "py313"]
target-version = ["py310", "py311", "py312", "py313", "py314"]
include = '\.pyi?$'
[tool.ruff]
+1 -2
View File
@@ -10,8 +10,7 @@ aiosqlite>=0.20.0,<1.0.0
numpy>=1.24,<3.0.0
# Image processing and thumbnails
# Updated for security patches (CVE-2023-50447, CVE-2024-28219)
pillow>=10.0.0,<13.0.0
pillow>=13.0.0,<14.0.0
# Video encoding/metadata support for the Majoor Save Video node
av>=12.0.0,<18.0.0
+1 -1
View File
@@ -215,7 +215,7 @@ async def test_create_watcher_starts_and_callbacks(monkeypatch):
return Result.Ok({})
class _Watcher:
def __init__(self, index_callback, remove_callback=None, move_callback=None):
def __init__(self, index_callback, remove_callback=None, move_callback=None, reindex_callback=None):
self._index_callback = index_callback
self._remove_callback = remove_callback
self._move_callback = move_callback
+2 -1
View File
@@ -4,6 +4,7 @@ import sqlite3
import pytest
from aiohttp import web
from aiohttp.test_utils import make_mocked_request
from mjr_am_backend.request_keys import REQUEST_USER_ID_KEY
from mjr_am_backend.routes import registry as r
from mjr_am_backend.shared import Result
@@ -99,7 +100,7 @@ def test_auth_error_response_or_none(monkeypatch):
monkeypatch.setattr(r, "_require_authenticated_user", lambda _req: Result.Ok("u1"))
out2 = r._auth_error_response_or_none(req)
assert out2 is None and req.get("mjr_user_id") == "u1"
assert out2 is None and req.get(REQUEST_USER_ID_KEY) == "u1"
@pytest.mark.asyncio
+66
View File
@@ -0,0 +1,66 @@
"""Rows that are one file under different Windows path spellings get merged."""
from __future__ import annotations
from pathlib import Path
import pytest
from mjr_am_backend import deps
from mjr_am_backend.adapters.db.schema_dedupe import merge_duplicate_asset_rows
async def _insert(db, filepath: str, **extra) -> int:
cols = {
"filename": "a.png", "subfolder": "Out", "filepath": filepath, "source": "output",
"kind": "image", "ext": ".png", "size": 1, "mtime": 1, **extra,
}
names = ",".join(cols)
res = await db.aexecute(
f"INSERT INTO assets ({names}) VALUES ({','.join('?' for _ in cols)})", tuple(cols.values())
)
assert res.ok, res.error
row = await db.aquery("SELECT id FROM assets WHERE filepath = ?", (filepath,))
return int(row.data[0]["id"])
@pytest.mark.asyncio
async def test_merges_case_variants_and_keeps_user_data(tmp_path: Path, monkeypatch):
monkeypatch.setattr(deps, "WATCHER_ENABLED", False)
res = await deps.build_services(str(tmp_path / "db.sqlite"))
assert res.ok, res.error
services = res.data
db = services["db"]
try:
a = await _insert(db, "C:\\Out\\a.png")
b = await _insert(db, "c:\\out\\a.png", job_id="job-1")
other = await _insert(db, "C:\\Out\\b.png")
await db.aexecute("INSERT INTO asset_metadata(asset_id, rating) VALUES (?, ?)", (a, 2))
await db.aexecute("INSERT INTO asset_metadata(asset_id, rating) VALUES (?, ?)", (b, 5))
await db.aexecute("INSERT OR IGNORE INTO tags(name) VALUES ('keep'), ('fav')")
tag_rows = await db.aquery("SELECT id, name FROM tags", ())
tag_ids = {r["name"]: int(r["id"]) for r in tag_rows.data}
await db.aexecute("INSERT INTO asset_tags(asset_id, tag_id) VALUES (?, ?)", (a, tag_ids["keep"]))
await db.aexecute("INSERT INTO asset_tags(asset_id, tag_id) VALUES (?, ?)", (b, tag_ids["fav"]))
assert (await merge_duplicate_asset_rows(db, case_insensitive=False)).data == 0
merged = await merge_duplicate_asset_rows(db, case_insensitive=True)
assert merged.ok and merged.data == 1
ids = {int(r["id"]) for r in (await db.aquery("SELECT id FROM assets", ())).data}
assert other in ids and len(ids) == 2
keep = (ids - {other}).pop()
rating = await db.aquery("SELECT rating FROM asset_metadata WHERE asset_id = ?", (keep,))
assert int(rating.data[0]["rating"]) == 5
tags = await db.aquery(
"SELECT t.name FROM asset_tags at JOIN tags t ON t.id = at.tag_id WHERE at.asset_id = ?", (keep,)
)
assert {r["name"] for r in tags.data} == {"keep", "fav"}
job = await db.aquery("SELECT job_id FROM assets WHERE id = ?", (keep,))
assert job.data[0]["job_id"] == "job-1"
assert (await merge_duplicate_asset_rows(db, case_insensitive=True)).data == 0
finally:
worker = services.get("rating_tags_sync")
if worker is not None:
worker.stop()
await db.aclose()
+43 -17
View File
@@ -1,4 +1,4 @@
"""Tests for the ``/api/v2/assets/*`` OpenAPI compat layer."""
"""Tests for the ``/mjr/am/compat/assets/*`` OpenAPI compat layer."""
import json
@@ -143,7 +143,7 @@ async def test_list_endpoint_returns_paginated_payload(monkeypatch):
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
app = _build_app()
req = make_mocked_request("GET", "/api/v2/assets?limit=2&offset=0", app=app)
req = make_mocked_request("GET", "/mjr/am/compat/assets?limit=2&offset=0", app=app)
match = await app.router.resolve(req)
resp = await match.handler(req)
payload = json.loads(resp.text)
@@ -166,7 +166,7 @@ async def test_get_endpoint_returns_404_when_missing(monkeypatch):
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
app = _build_app()
req = make_mocked_request("GET", "/api/v2/assets/999", app=app)
req = make_mocked_request("GET", "/mjr/am/compat/assets/999", app=app)
req.match_info["id"] = "999"
resp = await api_v2_assets._get_asset(req)
assert resp.status == 404
@@ -181,7 +181,7 @@ async def test_get_endpoint_returns_asset(monkeypatch):
return {"db": _FakeDB(_handle)}, None
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
req = make_mocked_request("GET", "/api/v2/assets/1")
req = make_mocked_request("GET", "/mjr/am/compat/assets/1")
req.match_info["id"] = "1"
resp = await api_v2_assets._get_asset(req)
assert resp.status == 200
@@ -199,7 +199,7 @@ async def test_hash_check_returns_200_on_match(monkeypatch):
return {"db": _FakeDB(_handle)}, None
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
req = make_mocked_request("HEAD", f"/api/v2/assets/hash/{'a' * 64}")
req = make_mocked_request("HEAD", f"/mjr/am/compat/assets/hash/{'a' * 64}")
req.match_info["hash"] = "a" * 64
resp = await api_v2_assets._check_asset_by_hash(req)
assert resp.status == 200
@@ -214,7 +214,7 @@ async def test_hash_check_returns_404_when_missing(monkeypatch):
return {"db": _FakeDB(_handle)}, None
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
req = make_mocked_request("HEAD", f"/api/v2/assets/hash/blake3:{'b' * 64}")
req = make_mocked_request("HEAD", f"/mjr/am/compat/assets/hash/blake3:{'b' * 64}")
req.match_info["hash"] = "blake3:" + "b" * 64
resp = await api_v2_assets._check_asset_by_hash(req)
assert resp.status == 404
@@ -226,7 +226,7 @@ async def test_hash_check_rejects_malformed_input(monkeypatch):
return {"db": _FakeDB(lambda *_: Result.Ok([]))}, None
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
req = make_mocked_request("HEAD", "/api/v2/assets/hash/notahash")
req = make_mocked_request("HEAD", "/mjr/am/compat/assets/hash/notahash")
req.match_info["hash"] = "notahash"
resp = await api_v2_assets._check_asset_by_hash(req)
assert resp.status == 404
@@ -241,7 +241,7 @@ async def test_tags_endpoint_returns_tags(monkeypatch):
return {"db": _FakeDB(_handle)}, None
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
req = make_mocked_request("GET", "/api/v2/assets/1/tags")
req = make_mocked_request("GET", "/mjr/am/compat/assets/1/tags")
req.match_info["id"] = "1"
resp = await api_v2_assets._get_asset_tags(req)
assert resp.status == 200
@@ -260,7 +260,7 @@ async def test_content_endpoint_returns_404_when_path_disallowed(monkeypatch):
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
monkeypatch.setattr(api_v2_assets, "_is_path_allowed", lambda *a, **kw: False)
req = make_mocked_request("GET", "/api/v2/assets/1/content")
req = make_mocked_request("GET", "/mjr/am/compat/assets/1/content")
req.match_info["id"] = "1"
resp = await api_v2_assets._get_asset_content(req)
assert resp.status == 404
@@ -289,7 +289,7 @@ def test_row_to_asset_omits_workflow_id_when_absent_everywhere():
# --------------------------------------------------------------------------- #
# /api/v2/assets/seed/status
# /mjr/am/compat/assets/seed/status
# --------------------------------------------------------------------------- #
@@ -310,7 +310,7 @@ async def test_seed_status_reports_totals_and_breakdown(monkeypatch):
return {"db": _FakeDB(_handle)}, None
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
req = make_mocked_request("GET", "/api/v2/assets/seed/status")
req = make_mocked_request("GET", "/mjr/am/compat/assets/seed/status")
resp = await api_v2_assets._seed_status(req)
body = json.loads(resp.text)
if resp.status != 200:
@@ -340,7 +340,7 @@ async def test_seed_status_handles_empty_db(monkeypatch):
return {"db": _FakeDB(_handle)}, None
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
req = make_mocked_request("GET", "/api/v2/assets/seed/status")
req = make_mocked_request("GET", "/mjr/am/compat/assets/seed/status")
resp = await api_v2_assets._seed_status(req)
body = json.loads(resp.text)
if resp.status != 200:
@@ -352,7 +352,7 @@ async def test_seed_status_handles_empty_db(monkeypatch):
# --------------------------------------------------------------------------- #
# /api/v2/assets/tags/refine
# /mjr/am/compat/assets/tags/refine
# --------------------------------------------------------------------------- #
@@ -375,7 +375,7 @@ async def test_tags_refine_returns_histogram(monkeypatch):
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
req = make_mocked_request(
"GET",
"/api/v2/assets/tags/refine?include_tags=cat&exclude_tags=blurry&limit=10",
"/mjr/am/compat/assets/tags/refine?include_tags=cat&exclude_tags=blurry&limit=10",
)
resp = await api_v2_assets._tags_refine(req)
body = json.loads(resp.text)
@@ -401,7 +401,7 @@ async def test_tags_refine_handles_no_filters(monkeypatch):
return {"db": _FakeDB(_handle)}, None
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
req = make_mocked_request("GET", "/api/v2/assets/tags/refine")
req = make_mocked_request("GET", "/mjr/am/compat/assets/tags/refine")
resp = await api_v2_assets._tags_refine(req)
body = json.loads(resp.text)
if resp.status != 200:
@@ -424,7 +424,7 @@ async def test_seed_status_is_not_eaten_by_id_route(monkeypatch):
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
app = _build_app()
req = make_mocked_request("GET", "/api/v2/assets/seed/status", app=app)
req = make_mocked_request("GET", "/mjr/am/compat/assets/seed/status", app=app)
match = await app.router.resolve(req)
if match.handler is not api_v2_assets._seed_status:
pytest.fail(f"seed/status routed to {match.handler.__name__}")
@@ -437,7 +437,7 @@ async def test_tags_refine_is_not_eaten_by_id_route(monkeypatch):
monkeypatch.setattr(api_v2_assets, "_require_services", _services)
app = _build_app()
req = make_mocked_request("GET", "/api/v2/assets/tags/refine", app=app)
req = make_mocked_request("GET", "/mjr/am/compat/assets/tags/refine", app=app)
match = await app.router.resolve(req)
if match.handler is not api_v2_assets._tags_refine:
pytest.fail(f"tags/refine routed to {match.handler.__name__}")
@@ -451,3 +451,29 @@ def test_api_error_response_maps_stable_codes_to_http_statuses():
assert invalid.status == 400
assert unavailable.status == 503
# --------------------------------------------------------------------------- #
# Namespace: never squat ComfyUI core's /api space by default
# --------------------------------------------------------------------------- #
def _registered_paths(**kwargs) -> set[str]:
routes = web.RouteTableDef()
api_v2_assets.register_api_v2_asset_routes(routes, **kwargs)
return {r.path for r in routes}
def test_compat_routes_live_under_extension_namespace(monkeypatch):
monkeypatch.delenv(api_v2_assets.LEGACY_ALIAS_ENV, raising=False)
paths = _registered_paths()
assert paths
assert all(p.startswith("/mjr/am/compat/assets") for p in paths)
def test_legacy_alias_is_opt_in(monkeypatch):
monkeypatch.setenv(api_v2_assets.LEGACY_ALIAS_ENV, "1")
paths = _registered_paths()
assert "/api/v2/assets" in paths
assert "/mjr/am/compat/assets" in paths
assert "/api/v2/assets" not in _registered_paths(include_legacy_alias=False)
+232
View File
@@ -0,0 +1,232 @@
"""Live change events for files added, moved or removed outside the Majoor UI.
Mirrors ComfyUI core's reconciler rules: only a confirmed absence removes an
indexed asset, an unreadable scan root is never pruned, and every removal or
move is announced so open grids update without a full reload.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from mjr_am_backend import deps
from mjr_am_backend.adapters import comfy_core
from mjr_am_backend.features.index import scan_prune
@pytest.fixture
def emitted(monkeypatch):
events: list[tuple[str, dict]] = []
def _send_event(event, payload, *_args):
events.append((event, payload))
return True
monkeypatch.setattr(comfy_core, "send_event", _send_event)
return events
def _payloads(events, name):
return [payload for event, payload in events if event == name]
async def _services(tmp_path: Path, monkeypatch):
monkeypatch.setattr(deps, "WATCHER_ENABLED", False)
res = await deps.build_services(str(tmp_path / "events.sqlite"))
assert res.ok, res.error
return res.data
async def _close(services):
worker = services.get("rating_tags_sync")
if worker is not None:
try:
worker.stop()
except Exception:
pass
await services["db"].aclose()
async def _scan(index, root: Path):
res = await index.scan_directory(
directory=str(root), recursive=False, incremental=True, source="output", root_id=None
)
assert res.ok, res.error
return res.data
async def _asset_id(index, path: Path) -> int:
res = await index.db.aquery("SELECT id FROM assets WHERE filename = ?", (path.name,))
assert res.ok and res.data, res.error
return int(res.data[0]["id"])
def test_path_state_distinguishes_missing_from_unreadable(tmp_path: Path, monkeypatch):
present = tmp_path / "a.png"
present.write_bytes(b"x")
assert scan_prune._path_state(str(present)) is True
assert scan_prune._path_state(str(tmp_path / "gone.png")) is False
def _boom(_path):
raise PermissionError("share offline")
monkeypatch.setattr(scan_prune.os, "stat", _boom)
assert scan_prune._path_state(str(present)) is None
assert scan_prune._find_missing_filepaths([(1, str(present))]) == []
@pytest.mark.asyncio
async def test_scan_prune_announces_removed_ids(tmp_path: Path, monkeypatch, emitted):
root = tmp_path / "root"
root.mkdir()
keep, gone = root / "keep.png", root / "gone.png"
keep.write_bytes(b"x")
gone.write_bytes(b"y")
services = await _services(tmp_path, monkeypatch)
try:
index = services["index"]
await _scan(index, root)
gone_id = await _asset_id(index, gone)
gone.unlink()
emitted.clear()
stats = await _scan(index, root)
assert stats["pruned"] == 1
removed = _payloads(emitted, "mjr-assets-removed")
assert removed and removed[0]["ids"] == [gone_id]
assert removed[0]["reason"] == "scan_prune"
complete = _payloads(emitted, "mjr-scan-complete")[-1]
assert complete["origin"] == "directory_scan"
assert complete["needs_refresh"] is True
assert "added_ids" not in complete
finally:
await _close(services)
@pytest.mark.asyncio
async def test_unreadable_scan_root_is_never_pruned(tmp_path: Path, monkeypatch, emitted):
root = tmp_path / "root"
root.mkdir()
(root / "a.png").write_bytes(b"x")
services = await _services(tmp_path, monkeypatch)
try:
index = services["index"]
await _scan(index, root)
monkeypatch.setattr(scan_prune, "_root_is_readable", lambda _d: False)
monkeypatch.setattr(scan_prune, "_path_state", lambda _p: False)
res = await scan_prune.prune_missing_assets_after_scan(
index._scanner, directory=str(root), recursive=False, source="output", root_id=None
)
assert res.ok and res.data == 0
assert not _payloads(emitted, "mjr-assets-removed")
count = await index.db.aquery("SELECT COUNT(*) AS n FROM assets", ())
assert int(count.data[0]["n"]) == 1
finally:
await _close(services)
@pytest.mark.asyncio
async def test_watcher_delete_and_move_emit_live_events(tmp_path: Path, monkeypatch, emitted):
root = tmp_path / "root"
root.mkdir()
deleted, moved = root / "deleted.png", root / "moved.png"
deleted.write_bytes(b"x")
moved.write_bytes(b"y")
services = await _services(tmp_path, monkeypatch)
try:
index = services["index"]
await _scan(index, root)
deleted_id = await _asset_id(index, deleted)
moved_id = await _asset_id(index, moved)
emitted.clear()
deleted.unlink()
assert (await index.remove_file(str(deleted))).ok
target = root / "renamed.png"
os.replace(moved, target)
rn = await index.rename_file(str(moved), str(target))
assert rn.ok, (rn.code, rn.error)
removed = _payloads(emitted, "mjr-assets-removed")
assert removed and removed[0]["ids"] == [deleted_id]
assert removed[0]["reason"] == "file_deleted"
updated = _payloads(emitted, "mjr-asset-updated")
assert any(int(p.get("id")) == moved_id and p.get("filename") == "renamed.png" for p in updated)
finally:
await _close(services)
_OLD_BUGGY_TRIGGERS = """
DROP TRIGGER IF EXISTS assets_fts_delete;
DROP TRIGGER IF EXISTS assets_fts_update;
CREATE TRIGGER assets_fts_delete AFTER DELETE ON assets BEGIN
DELETE FROM assets_fts WHERE rowid = old.id;
END;
CREATE TRIGGER assets_fts_update AFTER UPDATE ON assets BEGIN
UPDATE assets_fts SET filename = new.filename, subfolder = new.subfolder
WHERE rowid = new.id;
END;
"""
async def _fts_ids(db, term: str) -> set[int]:
res = await db.aquery("SELECT rowid AS id FROM assets_fts WHERE assets_fts MATCH ?", (term,))
assert res.ok, res.error
return {int(r["id"]) for r in res.data or []}
@pytest.mark.asyncio
async def test_rename_keeps_full_text_index_consistent(tmp_path: Path, monkeypatch, emitted):
root = tmp_path / "root"
root.mkdir()
src = root / "oldname.png"
src.write_bytes(b"x")
services = await _services(tmp_path, monkeypatch)
try:
index = services["index"]
await _scan(index, root)
asset_id = await _asset_id(index, src)
target = root / "freshname.png"
os.replace(src, target)
res = await index.rename_file(str(src), str(target))
assert res.ok, res.error
assert asset_id in await _fts_ids(index.db, "freshname")
assert asset_id not in await _fts_ids(index.db, "oldname")
finally:
await _close(services)
@pytest.mark.asyncio
async def test_startup_repairs_legacy_external_content_fts_triggers(tmp_path: Path, monkeypatch, emitted):
from mjr_am_backend.adapters.db.schema import ensure_indexes_and_triggers
root = tmp_path / "root"
root.mkdir()
src = root / "legacy.png"
src.write_bytes(b"x")
services = await _services(tmp_path, monkeypatch)
try:
index = services["index"]
await _scan(index, root)
assert (await index.db.aexecutescript(_OLD_BUGGY_TRIGGERS)).ok
assert (await ensure_indexes_and_triggers(index.db)).ok
trig = await index.db.aquery(
"SELECT sql FROM sqlite_master WHERE type='trigger' AND name='assets_fts_update'", ()
)
assert "'delete'" in str(trig.data[0]["sql"]).lower()
target = root / "migrated.png"
os.replace(src, target)
res = await index.rename_file(str(src), str(target))
assert res.ok, res.error
assert await _fts_ids(index.db, "migrated")
finally:
await _close(services)
+18 -1
View File
@@ -447,7 +447,7 @@ async def test_prepare_single_batch_entry_cache_miss_rich(tmp_path, monkeypatch)
scanner,
file_path=p,
base_dir=str(tmp_path),
incremental=False,
incremental=True,
fast=False,
journal_map={},
existing_map={},
@@ -458,6 +458,23 @@ async def test_prepare_single_batch_entry_cache_miss_rich(tmp_path, monkeypatch)
assert entry == {"action": "skipped"}
assert meta is None
# A non-incremental re-index (cache invalidated, e.g. a sidecar JSON was
# added next to the unchanged file) must re-extract instead of skipping.
entry, meta = await ib.prepare_single_batch_entry(
scanner,
file_path=p,
base_dir=str(tmp_path),
incremental=False,
fast=False,
journal_map={},
existing_map={},
cache_map={},
has_rich_meta_set={3},
stats=stats,
)
assert entry is None
assert meta is not None
@pytest.mark.asyncio
async def test_prepare_single_batch_entry_fast_mode(tmp_path, monkeypatch):
+1 -1
View File
@@ -153,7 +153,7 @@ async def test_ensure_vocab_and_autocomplete_paths():
def test_validate_paths_and_sanitize():
s = _mk()
assert s._validate_search_query(" ").code == "EMPTY_QUERY"
assert s._sanitize_fts_query("AND OR") == ""
assert s._sanitize_fts_query("AND OR") == "and* or*"
assert s._is_malformed_match_error("fts5: syntax error") is True
assert s._build_tags_text_clause() == ""
s2 = _mk(has_tags=False)
@@ -6,6 +6,12 @@ from mjr_am_backend.features.runtime import post_execution as mod
from mjr_am_backend.shared import Result
@pytest.fixture(autouse=True)
def _history_entry_present(monkeypatch):
"""Most tests stub history directly; don't wait for a real entry."""
monkeypatch.setattr(mod, "has_prompt_history_entry", lambda _prompt_id: True)
class _DB:
def __init__(self):
self.executed = []
@@ -285,3 +291,61 @@ async def test_ingest_prompt_outputs_assigns_rodin_package_context(monkeypatch,
]
assert len(rodin_updates) == 2
assert all(params[0] == "prompt-rodin" for params in rodin_updates)
@pytest.mark.asyncio
async def test_ingest_waits_for_history_written_after_on_prompt_end(monkeypatch, tmp_path: Path):
"""ComfyUI fires on_prompt_end before task_done() stores history: retry, don't index nothing."""
output = tmp_path / "out"
output.mkdir()
file_path = output / "late.png"
file_path.write_bytes(b"png")
state = {"checks": 0}
def _has_entry(_prompt_id):
state["checks"] += 1
return state["checks"] >= 3 # history appears on the 3rd check
def _outputs(_prompt_id):
if state["checks"] < 3:
return []
return [PromptOutputFile(path=str(file_path), node_id="9", node_type="SaveImage", item_type="output")]
sleeps = []
async def _fake_sleep(delay):
sleeps.append(delay)
monkeypatch.setattr(mod, "has_prompt_history_entry", _has_entry)
monkeypatch.setattr(mod, "get_prompt_output_files", _outputs)
monkeypatch.setattr(mod.asyncio, "sleep", _fake_sleep)
monkeypatch.setattr(mod, "send_event", lambda *_a, **_k: True)
monkeypatch.setattr(mod, "get_runtime_output_root", lambda: str(output))
async def _no_core(_prompt_id):
return []
monkeypatch.setattr(mod, "fetch_by_job_id", _no_core)
index = _Index()
res = await mod.ingest_prompt_outputs(index, "late-prompt")
assert res.ok
assert res.data["indexed"] == 1
assert len(sleeps) == 2
assert index.calls and index.calls[0][0] == [file_path.resolve()]
@pytest.mark.asyncio
async def test_wait_for_history_gives_up_without_blocking_forever(monkeypatch):
sleeps = []
async def _fake_sleep(delay):
sleeps.append(delay)
monkeypatch.setattr(mod, "has_prompt_history_entry", lambda _prompt_id: False)
monkeypatch.setattr(mod.asyncio, "sleep", _fake_sleep)
assert await mod._wait_for_history_entry("never") is False
assert len(sleeps) == len(mod._HISTORY_WAIT_DELAYS_S)
assert sum(sleeps) < 10
+2 -2
View File
@@ -1448,7 +1448,7 @@ async def test_watcher_toggle_start_success(monkeypatch) -> None:
monkeypatch.setattr(scan_mod, "build_watch_paths", lambda *_args, **_kwargs: [{"path": "C:/x"}])
class _OutputWatcher:
def __init__(self, index_cb, remove_callback=None, move_callback=None):
def __init__(self, index_cb, remove_callback=None, move_callback=None, reindex_callback=None):
self._index_cb = index_cb
self._remove_cb = remove_callback
self._move_cb = move_callback
@@ -1546,7 +1546,7 @@ async def test_watcher_scope_restart_success(monkeypatch) -> None:
monkeypatch.setattr(scan_mod, "build_watch_paths", lambda *_args, **_kwargs: [{"path": "C:/new"}])
class _OutputWatcher:
def __init__(self, index_cb, remove_callback=None, move_callback=None):
def __init__(self, index_cb, remove_callback=None, move_callback=None, reindex_callback=None):
self._index_cb = index_cb
self._remove_cb = remove_callback
self._move_cb = move_callback
+30 -2
View File
@@ -17,10 +17,12 @@ def test_sanitize_fts_query_reserved_tokens_are_removed():
assert out == "dog* cat*"
def test_sanitize_fts_query_only_reserved_falls_back_to_browse():
def test_sanitize_fts_query_only_reserved_searches_the_words():
# Lower-cased, these are plain barewords (FTS5 operators are upper-case
# only); dropping them all used to produce an empty MATCH and an error.
s = _mk_searcher()
out = s._sanitize_fts_query("AND OR NOT NEAR")
assert out == ""
assert out == "and* or* not* near*"
def test_sanitize_fts_query_normalizes_diacritics():
@@ -55,3 +57,29 @@ def test_sanitize_fts_query_removes_french_stopwords():
s = _mk_searcher()
out = s._sanitize_fts_query("une fleur dans le jardin avec lumiere")
assert out == "fleur* jardin* lumiere*"
def test_sanitize_fts_query_never_empty_for_word_input():
s = _mk_searcher()
# Search-as-you-type prefixes and punctuation-wrapped terms.
assert s._sanitize_fts_query("at-7") == "at* 7*"
assert s._sanitize_fts_query("(2)") == "2*"
assert s._sanitize_fts_query("NOT x") == "not* x*"
assert s._sanitize_fts_query("x") == "x*"
def test_sanitize_fts_query_keeps_non_latin_scripts():
s = _mk_searcher()
assert s._sanitize_fts_query("портрет") == "портрет*"
# Only Latin accents are folded, like FTS5 unicode61 remove_diacritics.
assert s._sanitize_fts_query("йод") == "йод*"
assert s._sanitize_fts_query("猫の写真") == "猫の写真*"
def test_punctuation_only_query_is_browse():
from mjr_am_backend.features.index.searcher import _is_browse_query
assert _is_browse_query('"') is True
assert _is_browse_query("*") is True
assert _is_browse_query("--") is True
assert _is_browse_query("a") is False
+63
View File
@@ -0,0 +1,63 @@
"""Single-word searches also match file names that *contain* the text."""
from __future__ import annotations
from pathlib import Path
import pytest
from mjr_am_backend import deps
from mjr_am_backend.features.index.searcher import _filename_substring_pattern
NAMES = ["ComfyUI_00001_.png", "Flux_portrait.png", "field.png", "sunflower.png", "cat.png", "50%_off.png", "a_b.png", "axb.png"]
def test_pattern_rules():
assert _filename_substring_pattern("F") == "%F%"
assert _filename_substring_pattern("red car") is None # multi-word: FTS only
assert _filename_substring_pattern('"exact"') is None
assert _filename_substring_pattern("*") is None
assert _filename_substring_pattern("a_b") == "%a\\_b%" # LIKE wildcards escaped
assert _filename_substring_pattern("50%") == "%50\\%%"
@pytest.mark.asyncio
async def test_single_letter_lists_every_file_containing_it(tmp_path: Path, monkeypatch):
root = tmp_path / "out"
root.mkdir()
for name in NAMES:
(root / name).write_bytes(b"\x89PNG\r\n\x1a\n")
monkeypatch.setattr(deps, "WATCHER_ENABLED", False)
res = await deps.build_services(str(tmp_path / "db.sqlite"))
assert res.ok, res.error
services = res.data
try:
index = services["index"]
scan = await index.scan_directory(
directory=str(root), recursive=False, incremental=True, source="output", root_id=None
)
assert scan.ok, scan.error
async def names(query: str, *, scoped: bool) -> set[str]:
if scoped:
r = await index.search_scoped(
query, roots=[str(root)], limit=50, offset=0, filters=None, include_total=True
)
else:
r = await index.search(query, limit=50, offset=0, filters=None, include_total=True)
assert r.ok, r.error
found = {a["filename"] for a in r.data["assets"]}
assert r.data.get("total") in (None, len(found))
return found
for scoped in (True, False):
assert await names("F", scoped=scoped) == {
"ComfyUI_00001_.png", "Flux_portrait.png", "field.png", "sunflower.png", "50%_off.png",
}
assert await names("flow", scoped=scoped) == {"sunflower.png"}
# "_" and "%" are literal, not LIKE wildcards.
assert await names("a_b", scoped=scoped) == {"a_b.png"}
finally:
worker = services.get("rating_tags_sync")
if worker is not None:
worker.stop()
await services["db"].aclose()
+8 -2
View File
@@ -429,7 +429,11 @@ def input_override_file(tmp_path, monkeypatch):
monkeypatch.setattr("mjr_am_backend.config._INPUT_DIR_OVERRIDE_FILE_PATH", path)
for name in ("MJR_AM_INPUT_DIRECTORY", "MAJOOR_INPUT_DIRECTORY"):
monkeypatch.delenv(name, raising=False)
return path
yield path
# The code under test writes these into os.environ itself; never let the
# override leak into later tests (it made get_input_directory() order-dependent).
for name in ("MJR_AM_INPUT_DIRECTORY", "MAJOOR_INPUT_DIRECTORY"):
os.environ.pop(name, None)
@pytest.mark.asyncio
@@ -464,7 +468,9 @@ async def test_input_directory_persistence_restart_clear(tmp_path, monkeypatch,
assert not paths._is_path_allowed(custom / "escape" / "a.png")
for name in ("MJR_AM_INPUT_DIRECTORY", "MAJOOR_INPUT_DIRECTORY"):
monkeypatch.delenv(name)
# pop, not monkeypatch.delenv: delenv would restore the value the code
# set (the custom root) at teardown and leak it into later tests.
os.environ.pop(name, None)
# The early-startup file resolves before the database is initialized.
assert get_input_directory() == str(custom)
input_override_file.unlink()
@@ -0,0 +1,63 @@
"""Which JSON files are workflows, and which are not, in the Workflow tab."""
from __future__ import annotations
import json
from pathlib import Path
from mjr_am_backend.features.workflows import service as svc
UI = {"last_node_id": 1, "nodes": [{"id": 1, "type": "KSampler"}], "links": [], "version": 0.4}
API = {"3": {"class_type": "KSampler", "inputs": {"seed": 1}}}
def _write(path: Path, data, *, bom: bool = False) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
raw = json.dumps(data).encode()
path.write_bytes((b"\xef\xbb\xbf" if bom else b"") + raw)
return path
def _listed(root: Path) -> dict[str, dict]:
res = svc._list_workflows_from_filesystem(
roots=[root], query="", limit=100, offset=0, sort="name", subfolder=""
)
assert res.ok, res.error
return {f"{c['subfolder']}/{c['filename']}".lstrip("/"): c for c in res.data["assets"]}
def test_formats():
assert svc.workflow_json_format(UI) == "ui"
assert svc.workflow_json_format({"nodes": [], "links": [], "version": 0.4}) == "ui"
assert svc.workflow_json_format(API) == "api"
assert svc.workflow_json_format({"prompt": json.dumps(API)}) == "api"
assert svc.workflow_json_format({"workflow": UI}) == "ui"
assert svc.workflow_json_format({"theme": "dark"}) == ""
assert svc.workflow_json_format({"nodes": [1, 2, 3]}) == ""
assert svc.workflow_json_format([UI]) == ""
def test_listing_skips_snapshots_and_non_workflows(tmp_path: Path):
root = tmp_path / "workflows"
_write(root / "flow.json", UI)
_write(root / ".history" / "flow" / "20260101-save.json", UI)
_write(root / "sub" / ".hidden.json", UI)
_write(root / "settings.json", {"theme": "dark"})
_write(root / "bom.json", UI, bom=True)
_write(root / "api.json", API)
listed = _listed(root)
assert set(listed) == {"flow.json", "bom.json", "api.json"}
assert listed["api.json"]["workflow_format"] == "api"
assert listed["api.json"]["node_count"] == 1
assert listed["flow.json"]["workflow_format"] == "ui"
def test_invalid_json_is_not_kept_as_seen_in_index(tmp_path: Path):
root = tmp_path / "workflows"
bogus = _write(root / "settings.json", {"theme": "dark"})
filepath, updated = svc._refresh_workflow_index_path(
None, bogus, root, {}, force_full_refresh=True # type: ignore[arg-type]
)
assert filepath is None and updated is False
-1
View File
@@ -1,7 +1,6 @@
import json
import pytest
from mjr_am_backend.features.metadata import extractor_registry as reg
PROMPT = {
+151
View File
@@ -0,0 +1,151 @@
"""Sidecar JSON next to images/videos/audio (``<file>.json`` / ``<stem>.json``)."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from mjr_am_backend import deps
from mjr_am_backend.features.metadata import sidecar
WORKFLOW = {
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{"id": 3, "type": "KSampler", "widgets_values": [42, "fixed", 20, 7.0, "euler", "normal", 1.0]},
],
"links": [],
"version": 0.4,
}
PROMPT = {"3": {"class_type": "KSampler", "inputs": {"seed": 42, "steps": 20}}}
def _png(path: Path) -> Path:
from PIL import Image
Image.new("RGB", (8, 8), (10, 20, 30)).save(path) # no embedded workflow
return path
def test_candidates_prefer_full_name_then_stem(tmp_path: Path):
img = tmp_path / "shot.png"
assert sidecar.sidecar_candidates(str(img)) == [str(img) + ".json", str(tmp_path / "shot.json")]
assert sidecar.sidecar_candidates(str(tmp_path / "a.json")) == [str(tmp_path / "a.json.json")]
def test_classify_wrapped_string_payload_and_bom(tmp_path: Path):
img = _png(tmp_path / "x.png")
payload = {"workflow": json.dumps(WORKFLOW), "prompt": PROMPT, "generation_time_ms": 1234}
(tmp_path / "x.png.json").write_bytes(b"\xef\xbb\xbf" + json.dumps(payload).encode())
data = sidecar.load_sidecar_metadata(str(img))
assert data is not None
assert data["workflow"]["nodes"][0]["type"] == "KSampler"
assert data["prompt"] == PROMPT
assert data["generation_time_ms"] == 1234
assert data["sidecar_path"].endswith("x.png.json")
def test_unrelated_json_is_not_a_sidecar(tmp_path: Path):
img = _png(tmp_path / "y.png")
(tmp_path / "y.json").write_text(json.dumps({"theme": "dark"}))
assert sidecar.load_sidecar_metadata(str(img)) is None
def test_embedded_metadata_wins_over_sidecar(tmp_path: Path):
img = _png(tmp_path / "z.png")
(tmp_path / "z.json").write_text(json.dumps(WORKFLOW))
combined = {"file_info": {"filepath": str(img)}, "workflow": {"nodes": [{"id": 1}]}, "prompt": PROMPT}
assert sidecar.apply_sidecar_fallback(combined) is False
assert combined["workflow"] == {"nodes": [{"id": 1}]}
@pytest.mark.asyncio
async def test_scan_links_stem_sidecar_workflow_to_image(tmp_path: Path, monkeypatch):
root = tmp_path / "out"
root.mkdir()
img = _png(root / "render_0001.png")
(root / "render_0001.json").write_text(json.dumps({"workflow": WORKFLOW, "prompt": PROMPT}))
monkeypatch.setattr(deps, "WATCHER_ENABLED", False)
res = await deps.build_services(str(tmp_path / "db.sqlite"))
assert res.ok, res.error
services = res.data
try:
index = services["index"]
scan = await index.scan_directory(
directory=str(root), recursive=False, incremental=False, source="output", root_id=None
)
assert scan.ok, scan.error
rows = await index.db.aquery(
"SELECT a.filename, m.has_workflow, m.has_generation_data FROM assets a "
"JOIN asset_metadata m ON m.asset_id = a.id",
(),
)
assert rows.ok, rows.error
by_name = {r["filename"]: r for r in rows.data}
assert set(by_name) == {img.name} # the .json itself is not an asset
assert int(by_name[img.name]["has_workflow"]) == 1
finally:
worker = services.get("rating_tags_sync")
if worker is not None:
worker.stop()
await services["db"].aclose()
def test_sidecar_media_paths_resolution(tmp_path: Path):
from mjr_am_backend.features.index.watcher import sidecar_media_paths
png = _png(tmp_path / "Shot.png")
(tmp_path / "Shot.mp4").write_bytes(b"0")
(tmp_path / "other.png").write_bytes(b"0")
assert sidecar_media_paths(str(tmp_path / "Shot.png.json")) == [str(png)]
stem_matches = sorted(sidecar_media_paths(str(tmp_path / "shot.json")))
assert stem_matches == sorted([str(png), str(tmp_path / "Shot.mp4")])
assert sidecar_media_paths(str(tmp_path / "missing.png.json")) == []
assert sidecar_media_paths(str(tmp_path / "notes.txt")) == []
@pytest.mark.asyncio
async def test_sidecar_added_after_indexing_is_picked_up_by_forced_reindex(tmp_path: Path, monkeypatch):
from mjr_am_backend.routes.handlers.scan_watcher import build_sidecar_reindex_callback
root = tmp_path / "out"
root.mkdir()
img = _png(root / "late.png")
monkeypatch.setattr(deps, "WATCHER_ENABLED", False)
res = await deps.build_services(str(tmp_path / "db.sqlite"))
assert res.ok, res.error
services = res.data
try:
index = services["index"]
scan = await index.scan_directory(
directory=str(root), recursive=False, incremental=True, source="output", root_id=None
)
assert scan.ok, scan.error
async def _has_workflow() -> int:
rows = await index.db.aquery(
"SELECT m.has_workflow FROM assets a JOIN asset_metadata m ON m.asset_id = a.id "
"WHERE a.filename = ?",
(img.name,),
)
return int(rows.data[0]["has_workflow"] or 0)
assert await _has_workflow() == 0
(root / "late.png.json").write_text(json.dumps(WORKFLOW))
# An incremental pass alone skips the unchanged image...
await index.index_paths(paths=[img], base_dir=str(root), incremental=True, source="output", root_id=None)
# ...the watcher's sidecar path forces re-extraction.
await build_sidecar_reindex_callback(index)([str(img)], str(root), "output", None)
assert await _has_workflow() == 1
finally:
worker = services.get("rating_tags_sync")
if worker is not None:
worker.stop()
await services["db"].aclose()
+19
View File
@@ -0,0 +1,19 @@
from pathlib import Path
import pytest
from packaging.requirements import Requirement
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.parametrize(
("version", "allowed"),
[("10.0.0", False), ("12.3.0", False), ("13.0.0", True)],
)
def test_pillow_security_constraint(version, allowed):
requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
pillow_requirement = next(line for line in requirements if line.startswith("pillow"))
pillow = Requirement(pillow_requirement)
assert (version in pillow.specifier) is allowed
assert f'"{pillow_requirement}",' in (ROOT / "pyproject.toml").read_text(encoding="utf-8")
+1 -1
View File
@@ -1,5 +1,5 @@
[tox]
envlist = quality, py310, py311, py312, py313
envlist = quality, py310, py311, py312, py313, py314
skipsdist = True
skip_missing_interpreters = True
+2 -1
View File
@@ -3,7 +3,8 @@
"compilerOptions": {
"strict": false,
"noImplicitAny": false,
"noEmit": true
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": [
"ui/tests/**/*.ts",
+8 -2
View File
@@ -35,9 +35,15 @@ export const comfyAlert = async (message: any, title = "Majoor", options: Record
if (extensionToast) {
try {
const detail = String(message || "");
if (typeof extensionToast.addAlert === "function") {
if (typeof extensionToast.info === "function") {
extensionToast.info(String(title || "Majoor"), {
description: detail,
duration: 5000,
closable: true,
});
} else if (typeof extensionToast.addAlert === "function") {
extensionToast.addAlert(detail);
} else {
} else if (typeof extensionToast.add === "function") {
extensionToast.add({
severity: "info",
summary: String(title || "Majoor"),
-12
View File
@@ -1,12 +0,0 @@
const BUILT_ASSET_NAMES = new Set<string>([]);
export function builtAssetUrl(name: any): string {
const safeName = String(name || "").trim();
if (!BUILT_ASSET_NAMES.has(safeName)) return "";
try {
const base = String(import.meta.url || "").includes("/chunks/") ? "../assets/" : "./assets/";
return new URL(`${base}${safeName}`, import.meta.url).href;
} catch {
return `./assets/${safeName}`;
}
}
+11 -1
View File
@@ -210,7 +210,17 @@ function _getBottomPanelController(manager: any | undefined): any {
export function getExtensionToastApi(app?: any): any {
const manager = getExtensionManager(app);
const toastApi = manager?.toast || null;
if (toastApi && typeof toastApi.add === "function") return toastApi;
if (!toastApi) return null;
// Current ToastManager exposes success/error/info/warning/loading and marks
// add()/addAlert() deprecated; accept either so toasts keep working once
// the deprecated methods are removed.
if (
typeof toastApi.add === "function" ||
typeof toastApi.success === "function" ||
typeof toastApi.info === "function"
) {
return toastApi;
}
return null;
}
-13
View File
@@ -9,15 +9,6 @@ export const APP_DEFAULTS = Object.freeze({
DEBUG_SAFE_LISTENERS: false,
DEBUG_VIEWER: false,
// Viewer
VIEWER_ALLOW_PAN_AT_ZOOM_1: false,
VIEWER_DISABLE_WEBGL_VIDEO: false,
VIEWER_DISABLE_WEBGL_AUDIO: false,
VIEWER_VIDEO_GRADE_THROTTLE_FPS: 12,
VIEWER_AUDIO_VISUALIZER_MODE: "artistic", // simple|artistic
VIEWER_AUDIO_VIS_FPS: 18,
VIEWER_SCOPES_FPS: 8,
// Grid
GRID_MIN_SIZE: 150,
FEED_GRID_MIN_SIZE: 120,
@@ -104,10 +95,6 @@ export const APP_DEFAULTS = Object.freeze({
RT_HYDRATE_PRUNE_BUDGET: 250,
RT_HYDRATE_SEEN_TTL_MS: 10 * 60 * 1000,
// Viewer metadata cache
VIEWER_META_TTL_MS: 30_000,
VIEWER_META_MAX_ENTRIES: 500,
// Workflow Minimap
WORKFLOW_MINIMAP_ENABLED: true,
WATCHER_DEBOUNCE_MS: 3000,
+1 -3
View File
@@ -37,9 +37,7 @@ export const getExtensionManagerDialog = (): MajoorExtensionDialog | null => {
export const getExtensionManagerToast = (): MajoorExtensionToast | null => {
try {
const toast = getHostToastApi();
if (toast && typeof toast.add === "function") {
return toast;
}
if (toast) return toast;
} catch (e) {
console.debug?.(e);
}
+2
View File
@@ -13,6 +13,8 @@ export const EVENTS = Object.freeze({
ENRICHMENT_STATUS: "mjr-enrichment-status",
DB_RESTORE_STATUS: "mjr-db-restore-status",
ASSETS_DELETED: "mjr:assets-deleted",
/** WS: assets removed outside the UI (watcher delete, scan prune). detail: { ids, count, truncated?, reason } */
ASSETS_REMOVED: "mjr-assets-removed",
SETTINGS_CHANGED: "mjr-settings-changed",
SELECTION_CHANGED: "mjr:selection-changed",
RELOAD_GRID: "mjr:reload-grid",
+115 -73
View File
@@ -48,7 +48,6 @@ import { findGraphNodeById, getHostRootGraph, walkGraphNodes } from "./graphTrav
// -- internal state ------------------------------------------------------------
let _app: any = null;
const HOST_QUEUE_PROMPT_BINDING_KEY = Symbol.for("mjr.host.queuePromptBinding");
const HOST_CANVAS_SELECTION_EVENT_TYPES = [
"selectionchange",
"selection-change",
@@ -252,6 +251,32 @@ export function registerSidebarTab(tabDef: any): boolean {
}
}
/**
* List every sidebar tab currently registered with the host (core + extensions).
* Returns an empty array when the host predates `getSidebarTabs`.
*/
export function listHostSidebarTabs(app: any = null): any[] {
try {
const manager = getExtensionManager(app || _app || getComfyApp());
const tabs = typeof manager?.getSidebarTabs === "function" ? manager.getSidebarTabs() : [];
return Array.isArray(tabs) ? tabs.filter(Boolean) : [];
} catch {
return [];
}
}
/** Unregister a sidebar tab by id through the official extension manager API. */
export function unregisterHostSidebarTab(id: string, app: any = null): boolean {
try {
const manager = getExtensionManager(app || _app || getComfyApp());
if (!id || typeof manager?.unregisterSidebarTab !== "function") return false;
manager.unregisterSidebarTab(id);
return true;
} catch {
return false;
}
}
export function registerSidebarTabForApp(app: any, tabDef: any): boolean {
try {
return registerSidebarTabCompat(app || _app || getComfyApp(), tabDef);
@@ -543,65 +568,6 @@ export function observeHostCanvasSelection(
};
}
export function wrapHostQueuePrompt(
options: {
api?: any;
app?: any;
owner?: any;
createWrapper?: ((originalQueuePrompt: any, api: any) => any) | null;
} = {},
): {
api: any;
owner: any;
originalQueuePrompt: any;
wrappedQueuePrompt: any;
restore: () => boolean;
} | null {
const api = options.api || getRawHostApi(options.app || _app || getComfyApp());
const owner = options.owner || null;
const createWrapper =
typeof options.createWrapper === "function" ? options.createWrapper : null;
if (!api || typeof api.queuePrompt !== "function" || !createWrapper) return null;
const existingBinding = api.queuePrompt?.[HOST_QUEUE_PROMPT_BINDING_KEY] || null;
if (existingBinding?.owner === owner) {
return existingBinding;
}
if (existingBinding?.owner && existingBinding.owner !== owner) {
return null;
}
const originalQueuePrompt = api.queuePrompt;
const wrappedQueuePrompt = createWrapper(originalQueuePrompt, api);
if (typeof wrappedQueuePrompt !== "function") return null;
const binding = {
api,
owner,
originalQueuePrompt,
wrappedQueuePrompt,
restore: () => {
try {
const activeBinding = api.queuePrompt?.[HOST_QUEUE_PROMPT_BINDING_KEY] || null;
if (activeBinding?.owner !== owner) return false;
api.queuePrompt = originalQueuePrompt;
return true;
} catch (e) {
console.debug?.(e);
return false;
}
},
};
Object.defineProperty(wrappedQueuePrompt, HOST_QUEUE_PROMPT_BINDING_KEY, {
configurable: true,
value: binding,
});
api.queuePrompt = wrappedQueuePrompt;
return binding;
}
export async function interruptHostExecution(app: any = null): Promise<boolean> {
const runtimeApp = app || _app || getComfyApp();
const liveApi = runtimeApp?.api && typeof runtimeApp.api.interrupt === "function" ? runtimeApp.api : null;
@@ -817,8 +783,63 @@ export function createHostNode(nodeType: any, app: any = null): any {
}
}
export function importWorkflowIntoHostCanvas(workflow: any, app: any = null): boolean {
function _isApiPromptGraph(value: any): boolean {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
if (Array.isArray(value.nodes)) return false;
const entries = Object.values(value).slice(0, 8);
if (!entries.length) return false;
return entries.every(
(node: any) =>
node && typeof node === "object" && typeof node.class_type === "string" && node.inputs && typeof node.inputs === "object",
);
}
/**
* Normalise what the Workflow tab / context menus hand us: a UI graph export,
* a `{ workflow }` / `{ prompt }` wrapper, or an API prompt graph.
*/
export function resolveHostWorkflowPayload(workflow: any): { format: "ui" | "api" | ""; data: any } {
if (!workflow || typeof workflow !== "object") return { format: "", data: null };
if (Array.isArray(workflow.nodes)) return { format: "ui", data: workflow };
if (workflow.workflow && typeof workflow.workflow === "object" && Array.isArray(workflow.workflow.nodes)) {
return { format: "ui", data: workflow.workflow };
}
let prompt = workflow.prompt;
if (typeof prompt === "string") {
try {
prompt = JSON.parse(prompt);
} catch {
prompt = null;
}
}
if (_isApiPromptGraph(prompt)) return { format: "api", data: prompt };
if (_isApiPromptGraph(workflow)) return { format: "api", data: workflow };
return { format: "", data: null };
}
export function importWorkflowIntoHostCanvas(
workflow: any,
app: any = null,
options: { filename?: string } = {},
): boolean {
const runtimeApp = app || _app || getComfyApp();
const resolved = resolveHostWorkflowPayload(workflow);
if (resolved.format === "api") {
// API prompts have no layout; graph loaders show an empty canvas.
// The frontend rebuilds a graph from them with loadApiJson.
if (typeof runtimeApp?.loadApiJson !== "function") return false;
try {
const result = runtimeApp.loadApiJson(resolved.data, String(options.filename || "workflow.json"));
if (result && typeof result.then === "function") {
result.then(undefined, (e: unknown) => console.debug?.(e));
}
return true;
} catch (e) {
console.debug?.(e);
return false;
}
}
if (resolved.format === "ui") workflow = resolved.data;
if (!workflow || typeof workflow !== "object") return false;
try {
const importCandidates = [
@@ -839,7 +860,11 @@ export function importWorkflowIntoHostCanvas(workflow: any, app: any = null): bo
}
}
if (typeof runtimeApp?.loadGraphData === "function") {
runtimeApp.loadGraphData(workflow);
// Async on current frontends: never leave a rejection unhandled.
const result = runtimeApp.loadGraphData(workflow);
if (result && typeof result.then === "function") {
result.then(undefined, (e: unknown) => console.debug?.(e));
}
return true;
}
const graph = _getHostGraph(runtimeApp);
@@ -878,20 +903,30 @@ export function importWorkflowPreferHostTab(
export function serializeCurrentHostWorkflow(app: any = null): any {
try {
const runtimeApp = app || _app || getComfyApp();
if (typeof runtimeApp?.graphToPrompt === "function") {
const data = runtimeApp.graphToPrompt();
if (data?.workflow && typeof data.workflow === "object") return data.workflow;
}
const graph = _getHostGraph(runtimeApp);
if (typeof graph?.serialize === "function") {
const workflow = graph.serialize();
if (workflow && typeof workflow === "object") return workflow;
}
const rootGraph = runtimeApp?.rootGraph || runtimeApp?.graph?.rootGraph || null;
// Prefer the root graph: with subgraphs, `app.canvas.graph` (and on some
// builds `app.graph`) can point at the subgraph currently being edited,
// which would serialize a fragment instead of the whole workflow.
const rootGraph = getHostRootGraph(runtimeApp);
if (typeof rootGraph?.serialize === "function") {
const workflow = rootGraph.serialize();
if (workflow && typeof workflow === "object") return workflow;
}
const graph = _getHostGraph(runtimeApp);
if (graph !== rootGraph && typeof graph?.serialize === "function") {
const workflow = graph.serialize();
if (workflow && typeof workflow === "object") return workflow;
}
// Legacy hosts only: modern ComfyUI frontends make graphToPrompt() async
// (it returns a Promise), so a synchronous caller cannot use its result.
// Never leave a rejected promise unhandled when we discard it.
if (typeof runtimeApp?.graphToPrompt === "function") {
const data = runtimeApp.graphToPrompt();
if (data && typeof data.then === "function") {
data.then(undefined, (e: unknown) => console.debug?.(e));
return null;
}
if (data?.workflow && typeof data.workflow === "object") return data.workflow;
}
} catch (e) {
console.debug?.(e);
}
@@ -909,6 +944,11 @@ export function isHostWorkflowDirty(app: any = null): boolean | null {
const runtimeApp = app || _app || getComfyApp();
if (!runtimeApp) return null;
// ComfyUI frontend >= 1.2x exposes the workflow store on the extension
// manager; its `isModified` flag is the authoritative dirty signal.
const activeWorkflow = runtimeApp?.extensionManager?.workflow?.activeWorkflow;
if (typeof activeWorkflow?.isModified === "boolean") return activeWorkflow.isModified;
const candidates = [
runtimeApp?.isDirty,
runtimeApp?.dirty,
@@ -1227,7 +1267,6 @@ export const hostAdapter = {
fetchHostApi,
queueHostPrompt,
observeHostCanvasSelection,
wrapHostQueuePrompt,
interruptHostExecution,
getSelectedHostCanvasNodes,
getSelectedHostCanvasNodeIds,
@@ -1237,9 +1276,12 @@ export const hostAdapter = {
refreshHostCanvasGraph,
notifyHostNodeGraphChanged,
importWorkflowIntoHostCanvas,
resolveHostWorkflowPayload,
importWorkflowPreferHostTab,
isHostWorkflowDirty,
serializeCurrentHostWorkflow,
listHostSidebarTabs,
unregisterHostSidebarTab,
addNodeToHostGraph,
createHostNode,
focusHostCanvasNode,
-28
View File
@@ -31,8 +31,6 @@ export const GENERATED_TRANSLATIONS = {
"setting.nav.infinite.desc": "滚动时自动加载更多文件。",
"setting.grid.pagesize.name": "主要:网格页面大小",
"setting.grid.pagesize.desc": "网格中每页/请求加载的资源数量。",
"setting.viewer.pan.name": "主要:平移而不缩放",
"setting.viewer.pan.desc": "即使在缩放级别 1 下也允许平移图像。",
"setting.minimap.enabled.name": "主要:启用小地图",
"setting.minimap.enabled.desc": "工作流程小地图的全局激活。",
"setting.scan.startup.name": "主要:启动时自动扫描",
@@ -188,10 +186,8 @@ export const GENERATED_TRANSLATIONS = {
"msg.errorLoading": "加载错误",
"msg.errorLoadingFolders": "加载文件夹时出错",
"msg.noGenerationData": "找不到该文件的生成数据。",
"msg.rawMetadata": "原始元数据",
"viewer.genInfo": "一代信息",
"viewer.workflow": "工作流程",
"viewer.metadata": "元数据",
"viewer.noWorkflow": "无工作流程数据",
"viewer.noMetadata": "没有可用的元数据",
"viewer.copySuccess": "已复制到剪贴板!",
@@ -341,8 +337,6 @@ export const GENERATED_TRANSLATIONS = {
"setting.nav.infinite.desc": "スクロール時にさらに多くのファイルを自動的にロードします。",
"setting.grid.pagesize.name": "メジャー: グリッド ページ サイズ",
"setting.grid.pagesize.desc": "グリッド内のページ/リクエストごとにロードされるアセットの数。",
"setting.viewer.pan.name": "メジャー: ズームなしのパン",
"setting.viewer.pan.desc": "ズーム レベル 1 であっても画像のパンを許可します。",
"setting.minimap.enabled.name": "メジャー: ミニマップを有効にする",
"setting.minimap.enabled.desc": "ワークフロー ミニマップのグローバルなアクティブ化。",
"setting.scan.startup.name": "メジャー: 起動時に自動スキャン",
@@ -498,10 +492,8 @@ export const GENERATED_TRANSLATIONS = {
"msg.errorLoading": "読み込みエラー",
"msg.errorLoadingFolders": "フォルダーの読み込みエラー",
"msg.noGenerationData": "このファイルの世代データは見つかりませんでした。",
"msg.rawMetadata": "生のメタデータ",
"viewer.genInfo": "世代情報",
"viewer.workflow": "ワークフロー",
"viewer.metadata": "メタデータ",
"viewer.noWorkflow": "ワークフローデータがありません",
"viewer.noMetadata": "利用可能なメタデータがありません",
"viewer.copySuccess": "クリップボードにコピーされました!",
@@ -651,8 +643,6 @@ export const GENERATED_TRANSLATIONS = {
"setting.nav.infinite.desc": "스크롤할 때 자동으로 더 많은 파일을 로드합니다.",
"setting.grid.pagesize.name": "전공: 그리드 페이지 크기",
"setting.grid.pagesize.desc": "그리드의 페이지/요청당 로드된 자산 수입니다.",
"setting.viewer.pan.name": "전공: 확대/축소 없이 이동",
"setting.viewer.pan.desc": "확대/축소 수준 1에서도 이미지 패닝을 허용합니다.",
"setting.minimap.enabled.name": "주요: 미니맵 활성화",
"setting.minimap.enabled.desc": "작업흐름 미니맵의 전역 활성화.",
"setting.scan.startup.name": "주요: 시작 시 자동 스캔",
@@ -808,10 +798,8 @@ export const GENERATED_TRANSLATIONS = {
"msg.errorLoading": "로드 중 오류 발생",
"msg.errorLoadingFolders": "폴더를 로드하는 중에 오류가 발생했습니다.",
"msg.noGenerationData": "이 파일에 대한 생성 데이터가 없습니다.",
"msg.rawMetadata": "원시 메타데이터",
"viewer.genInfo": "세대 정보",
"viewer.workflow": "작업흐름",
"viewer.metadata": "메타데이터",
"viewer.noWorkflow": "워크플로 데이터 없음",
"viewer.noMetadata": "사용 가능한 메타데이터가 없습니다.",
"viewer.copySuccess": "클립보드에 복사되었습니다!",
@@ -961,8 +949,6 @@ export const GENERATED_TRANSLATIONS = {
"setting.nav.infinite.desc": "स्क्रॉल करते समय स्वचालित रूप से अधिक फ़ाइलें लोड करें।",
"setting.grid.pagesize.name": "प्रमुख: ग्रिड पृष्ठ का आकार",
"setting.grid.pagesize.desc": "ग्रिड में प्रति पृष्ठ/अनुरोध पर लोड की गई संपत्तियों की संख्या।",
"setting.viewer.pan.name": "मेजर: ज़ूम के बिना पैन",
"setting.viewer.pan.desc": "ज़ूम स्तर 1 पर भी छवि को पैन करने की अनुमति दें।",
"setting.minimap.enabled.name": "मेजर: मिनिमैप सक्षम करें",
"setting.minimap.enabled.desc": "वर्कफ़्लो मिनिमैप का वैश्विक सक्रियण।",
"setting.scan.startup.name": "प्रमुख: स्टार्टअप पर ऑटो-स्कैन",
@@ -1118,10 +1104,8 @@ export const GENERATED_TRANSLATIONS = {
"msg.errorLoading": "लोड होने मे त्रुटि",
"msg.errorLoadingFolders": "फ़ोल्डर लोड करने में त्रुटि",
"msg.noGenerationData": "इस फ़ाइल के लिए कोई पीढ़ी डेटा नहीं मिला.",
"msg.rawMetadata": "कच्चा मेटाडेटा",
"viewer.genInfo": "पीढ़ी की जानकारी",
"viewer.workflow": "कार्यप्रवाह",
"viewer.metadata": "मेटाडाटा",
"viewer.noWorkflow": "कोई वर्कफ़्लो डेटा नहीं",
"viewer.noMetadata": "कोई मेटाडेटा उपलब्ध नहीं है",
"viewer.copySuccess": "क्लिपबोर्ड पर कॉपी किया गया!",
@@ -1271,8 +1255,6 @@ export const GENERATED_TRANSLATIONS = {
"setting.nav.infinite.desc": "Carregue automaticamente mais arquivos ao rolar.",
"setting.grid.pagesize.name": "Principal: Tamanho da página da grade",
"setting.grid.pagesize.desc": "Número de ativos carregados por página/solicitação na grade.",
"setting.viewer.pan.name": "Principal: panorâmica sem zoom",
"setting.viewer.pan.desc": "Permitir deslocar a imagem mesmo no nível de zoom 1.",
"setting.minimap.enabled.name": "Principal: Habilitar Minimapa",
"setting.minimap.enabled.desc": "Ativação global do minimapa do fluxo de trabalho.",
"setting.scan.startup.name": "Principal: Verificação automática na inicialização",
@@ -1428,10 +1410,8 @@ export const GENERATED_TRANSLATIONS = {
"msg.errorLoading": "Erro ao carregar",
"msg.errorLoadingFolders": "Erro ao carregar pastas",
"msg.noGenerationData": "Nenhum dado de geração encontrado para este arquivo.",
"msg.rawMetadata": "Metadados brutos",
"viewer.genInfo": "Informações de geração",
"viewer.workflow": "Fluxo de trabalho",
"viewer.metadata": "Metadados",
"viewer.noWorkflow": "Nenhum dado de fluxo de trabalho",
"viewer.noMetadata": "Nenhum metadado disponível",
"viewer.copySuccess": "Copiado para a área de transferência!",
@@ -1581,8 +1561,6 @@ export const GENERATED_TRANSLATIONS = {
"setting.nav.infinite.desc": "Cargue automáticamente más archivos al desplazarse.",
"setting.grid.pagesize.name": "Mayor: Tamaño de página de cuadrícula",
"setting.grid.pagesize.desc": "Número de recursos cargados por página/solicitud en la cuadrícula.",
"setting.viewer.pan.name": "Mayor: panorámica sin zoom",
"setting.viewer.pan.desc": "Permita desplazar la imagen incluso en el nivel de zoom 1.",
"setting.minimap.enabled.name": "Mayor: habilitar minimapa",
"setting.minimap.enabled.desc": "Activación global del minimapa del flujo de trabajo.",
"setting.scan.startup.name": "Majoor: Escaneo automático al iniciar",
@@ -1738,10 +1716,8 @@ export const GENERATED_TRANSLATIONS = {
"msg.errorLoading": "Error al cargar",
"msg.errorLoadingFolders": "Error al cargar carpetas",
"msg.noGenerationData": "No se encontraron datos de generación para este archivo.",
"msg.rawMetadata": "Metadatos sin procesar",
"viewer.genInfo": "Información de generación",
"viewer.workflow": "Flujo de trabajo",
"viewer.metadata": "Metadatos",
"viewer.noWorkflow": "Sin datos de flujo de trabajo",
"viewer.noMetadata": "No hay metadatos disponibles",
"viewer.copySuccess": "¡Copiado al portapapeles!",
@@ -1891,8 +1867,6 @@ export const GENERATED_TRANSLATIONS = {
"setting.nav.infinite.desc": "Автоматически загружать больше файлов при прокрутке.",
"setting.grid.pagesize.name": "Основное: Размер страницы сетки",
"setting.grid.pagesize.desc": "Количество ресурсов, загруженных на страницу/запрос в сетке.",
"setting.viewer.pan.name": "Мажор: Панорамирование без масштабирования",
"setting.viewer.pan.desc": "Разрешить панорамирование изображения даже при уровне масштабирования 1.",
"setting.minimap.enabled.name": "Майор: включить миникарту",
"setting.minimap.enabled.desc": "Глобальная активация миникарты рабочего процесса.",
"setting.scan.startup.name": "Основное: автоматическое сканирование при запуске.",
@@ -2048,10 +2022,8 @@ export const GENERATED_TRANSLATIONS = {
"msg.errorLoading": "Ошибка загрузки",
"msg.errorLoadingFolders": "Ошибка загрузки папок.",
"msg.noGenerationData": "Для этого файла не найдены данные о создании.",
"msg.rawMetadata": "Необработанные метаданные",
"viewer.genInfo": "Информация о поколении",
"viewer.workflow": "Рабочий процесс",
"viewer.metadata": "Метаданные",
"viewer.noWorkflow": "Нет данных о рабочем процессе",
"viewer.noMetadata": "Нет доступных метаданных",
"viewer.copySuccess": "Скопировано в буфер обмена!",

Some files were not shown because too many files have changed in this diff Show More