Bring the classic viewer's review tools to the Studio viewer as a HUD
A tool rail on the left of the stage adds channels, exposure and gamma, zebra, histogram and waveform scopes, composition guides, aspect 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 are SVG filters so they also apply to playing video; video compare keeps a second player in sync. 3D models and the A/B / side-by-side open modes now use the Studio viewer, audio files show a live spectrum, and the inspector's tag editor no longer steals focus after navigating.
This commit is contained in:
+4
-1
@@ -31,11 +31,14 @@ All notable changes to this project are documented in this file.
|
||||
- **`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). Compare modes, 3D models and the analysis tools stay in the classic viewer (one click away); *Viewer → Use the classic viewer* switches back entirely.
|
||||
- **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`.
|
||||
|
||||
### Fixed (follow-ups)
|
||||
- **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.
|
||||
|
||||
+9
-9
@@ -1,14 +1,14 @@
|
||||
import { $t as e, g as t, y as n } from "./viewerRuntimeHosts-CW2DfHq_.js";
|
||||
import { C as r, H as i, I as a, M as o, O as s, P as c, V as l, at as u, j as d, m as f, o as p, r as m, v as h, w as g, x as _, z as v } from "./events-cBMXVULQ.js";
|
||||
import { $t as e, g as t, y as n } from "./viewerRuntimeHosts-XPTU8ZhU.js";
|
||||
import { C as r, H as i, I as a, M as o, O as s, P as c, V as l, at as u, j as d, m as f, o as p, r as m, v as h, w as g, x as _, z as v } from "./events-CttACGx4.js";
|
||||
import { a as y, i as b, o as x, s as S } from "./graphTraversal-DTdQWkn5.js";
|
||||
import { D as C, _ as w, a as T, b as E, g as D, i as O, y as k } from "./viewerOpenRequest-_DqD1rXA.js";
|
||||
import { r as A, tt as j, u as M, v as N } from "./SidebarWorkflowSection-F3hoMYG1.js";
|
||||
import { _ as P, a as F, c as I, d as L, f as R, g as z, h as ee, i as B, l as V, m as te, o as ne, p as H, r as re, s as U, t as ie, u as W, v as ae } from "./openMajoorSettings-B9ZJ7bmZ.js";
|
||||
import { F as oe } from "./mjr-primevue-3mpaVb1C.js";
|
||||
import { a as se, n as ce, r as le } from "./model3dRenderer-B-J9oQzQ.js";
|
||||
import { D as C, _ as w, a as T, b as E, g as D, i as O, y as k } from "./viewerOpenRequest-CZXk8yvU.js";
|
||||
import { r as A, tt as j, u as M, v as N } from "./SidebarWorkflowSection-DrSf3i6L.js";
|
||||
import { _ as P, a as F, c as I, d as L, f as R, g as z, h as ee, i as B, l as V, m as te, o as ne, p as H, r as re, s as U, t as ie, u as W, v as ae } from "./openMajoorSettings-Do4UDwGh.js";
|
||||
import { I as oe } from "./mjr-primevue-C07IsEdU.js";
|
||||
import { a as se, n as ce, r as le } from "./model3dRenderer-CIUrxTBK.js";
|
||||
import { i as ue, o as de, r as fe, t as pe } from "./geninfoParser-CgHp-y-n.js";
|
||||
import { t as me } from "./StudioPlayer-DXSy5UA5.js";
|
||||
import { t as he } from "./genInfo-B3LeL9TU.js";
|
||||
import { t as me } from "./StudioPlayer-ZPvmvcGX.js";
|
||||
import { t as he } from "./genInfo-B9wv7JDq.js";
|
||||
// ui/features/viewer/floatingViewerConstants.ts
|
||||
var G = Object.freeze({
|
||||
SIMPLE: "simple",
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { Z as e, o as t, r as n } from "./events-cBMXVULQ.js";
|
||||
import { t as r } from "./floatingViewerManager-CKeu6wKp.js";
|
||||
import { Z as e, o as t, r as n } from "./events-CttACGx4.js";
|
||||
import { t as r } from "./floatingViewerManager-CLgIT9uN.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",
|
||||
Vendored
+343
-343
File diff suppressed because it is too large
Load Diff
Vendored
-597
@@ -1,597 +0,0 @@
|
||||
import { m as e } from "./events-cBMXVULQ.js";
|
||||
import { g as t, h as n } from "./viewerOpenRequest-_DqD1rXA.js";
|
||||
import { A as r, B as i, C as a, D as o, E as s, H as c, K as l, N as u, O as d, S as ee, T as f, Y as p, dt as m, ft as h, it as g, j as _, k as v, pt as y, q as te, ut as b, z as ne } from "./mjr-primevue-3mpaVb1C.js";
|
||||
// ui/features/viewer/studio/studioMedia.ts
|
||||
var x = /* @__PURE__ */ new Set([
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"webp",
|
||||
"gif",
|
||||
"bmp",
|
||||
"avif",
|
||||
"tif",
|
||||
"tiff",
|
||||
"jxl",
|
||||
"exr",
|
||||
"svg"
|
||||
]), re = /* @__PURE__ */ new Set([
|
||||
"mp4",
|
||||
"webm",
|
||||
"mov",
|
||||
"mkv",
|
||||
"avi",
|
||||
"m4v"
|
||||
]), S = /* @__PURE__ */ new Set([
|
||||
"mp3",
|
||||
"wav",
|
||||
"flac",
|
||||
"ogg",
|
||||
"opus",
|
||||
"m4a",
|
||||
"aac",
|
||||
"aiff"
|
||||
]), C = /* @__PURE__ */ new Set([
|
||||
"glb",
|
||||
"gltf",
|
||||
"obj",
|
||||
"fbx",
|
||||
"stl",
|
||||
"ply",
|
||||
"splat",
|
||||
"ksplat",
|
||||
"spz"
|
||||
]);
|
||||
function w(e) {
|
||||
let t = String(e?.filename || e?.name || e?.filepath || "").trim(), n = t.lastIndexOf(".");
|
||||
return n >= 0 ? t.slice(n + 1).toLowerCase() : "";
|
||||
}
|
||||
function T(e) {
|
||||
let t = String(e?.kind || "").trim().toLowerCase();
|
||||
if (t === "image" || t === "video" || t === "audio" || t === "model3d") return t;
|
||||
let n = w(e);
|
||||
return x.has(n) ? "image" : re.has(n) ? "video" : S.has(n) ? "audio" : C.has(n) ? "model3d" : "other";
|
||||
}
|
||||
function E(e, { withCentis: t = !0 } = {}) {
|
||||
let n = Math.floor((Number.isFinite(e) && e > 0 ? e : 0) * 100 + 1e-6), r = n % 100, i = Math.floor(n / 100), a = i % 60, o = Math.floor(i / 60) % 60, s = Math.floor(i / 3600), c = s > 0 ? String(o).padStart(2, "0") : String(o), l = `${s > 0 ? `${s}:` : ""}${c}:${String(a).padStart(2, "0")}`;
|
||||
return t ? `${l}.${String(r).padStart(2, "0")}` : l;
|
||||
}
|
||||
function D(e, t) {
|
||||
return !t || !Number.isFinite(t) || t <= 0 ? 0 : Math.max(0, Math.floor(Math.max(0, e) * t + 1e-6));
|
||||
}
|
||||
function O(e, t) {
|
||||
return !t || !Number.isFinite(t) || t <= 0 ? 0 : Math.max(0, (Math.max(0, Math.floor(e)) + .25) / t);
|
||||
}
|
||||
function ie(e, t, n, r) {
|
||||
let i = t && Number.isFinite(t) && t > 0 ? t : 24, a = O(D(e, i) + n, i);
|
||||
return Math.min(Math.max(0, a), Math.max(0, (Number.isFinite(r) && r > 0 ? r : a) - .5 / i));
|
||||
}
|
||||
function ae(e, t, n) {
|
||||
let r = Number(n);
|
||||
return Number.isFinite(r) && r > 0 ? Math.floor(r) : !t || !Number.isFinite(e) || e <= 0 ? null : Math.max(1, Math.round(e * t));
|
||||
}
|
||||
function oe(e, t) {
|
||||
if (e.a == null || e.b == null) return null;
|
||||
let n = Number.isFinite(t) && t > 0 ? t : Math.max(e.a, e.b), r = Math.min(Math.max(0, Math.min(e.a, e.b)), n), i = Math.min(Math.max(0, Math.max(e.a, e.b)), n);
|
||||
return i - r >= .02 ? {
|
||||
a: r,
|
||||
b: i
|
||||
} : null;
|
||||
}
|
||||
function se(e, t, n) {
|
||||
let r = oe(t, n);
|
||||
return r && (e >= r.b || e < r.a - .05) ? r.a : null;
|
||||
}
|
||||
var ce = [
|
||||
.25,
|
||||
.5,
|
||||
1,
|
||||
1.5,
|
||||
2
|
||||
];
|
||||
function k(e, r) {
|
||||
return t(e) ?? n(r?.fps_raw ?? r?.fps ?? r?.frame_rate) ?? n(e?.fps);
|
||||
}
|
||||
function A(e) {
|
||||
let t = [], n = Number(e?.width), r = Number(e?.height);
|
||||
n > 0 && r > 0 && t.push(`${n}×${r}`);
|
||||
let i = w(e);
|
||||
i && t.push(i.toUpperCase());
|
||||
let a = Number(e?.duration);
|
||||
a > 0 && t.push(E(a, { withCentis: !1 }));
|
||||
let o = Number(e?.size);
|
||||
return o > 0 && t.push(j(o)), t.join(" · ");
|
||||
}
|
||||
function j(e) {
|
||||
if (!Number.isFinite(e) || e <= 0) return "";
|
||||
let t = [
|
||||
"B",
|
||||
"KB",
|
||||
"MB",
|
||||
"GB"
|
||||
], n = e, r = 0;
|
||||
for (; n >= 1024 && r < t.length - 1;) n /= 1024, r += 1;
|
||||
return `${n >= 10 || r === 0 ? Math.round(n) : n.toFixed(1)} ${t[r]}`;
|
||||
}
|
||||
function M(e, t, n = 40) {
|
||||
if (e <= 0) return {
|
||||
start: 0,
|
||||
end: 0
|
||||
};
|
||||
let r = n * 2 + 1;
|
||||
if (e <= r) return {
|
||||
start: 0,
|
||||
end: e
|
||||
};
|
||||
let i = Math.max(0, t - n), a = i + r;
|
||||
return a > e && (a = e, i = e - r), {
|
||||
start: i,
|
||||
end: a
|
||||
};
|
||||
}
|
||||
// ui/vue/components/viewer/studio/StudioPlayer.vue?vue&type=script&setup=true&lang.ts
|
||||
var le = ["src"], ue = ["src"], de = {
|
||||
key: 3,
|
||||
class: "mjr-studio-player__error"
|
||||
}, fe = [
|
||||
"aria-label",
|
||||
"aria-valuemax",
|
||||
"aria-valuenow"
|
||||
], pe = { class: "mjr-studio-player__row" }, me = { class: "mjr-studio-player__time" }, he = { class: "is-dim" }, ge = ["title"], _e = ["title"], ve = { class: "mjr-studio-player__group" }, ye = ["aria-label", "aria-valuenow"], N = /* @__PURE__ */ u({
|
||||
__name: "StudioPlayer",
|
||||
props: {
|
||||
src: {},
|
||||
kind: {},
|
||||
fps: {},
|
||||
frameCount: {},
|
||||
filename: {},
|
||||
initialMuted: { type: Boolean },
|
||||
initialRate: {},
|
||||
compact: { type: Boolean }
|
||||
},
|
||||
emits: ["ended"],
|
||||
setup(t, { expose: n, emit: u }) {
|
||||
let x = t, re = u, S = g(null), C = g(null), w = g(null), T = g(!1), O = g(0), k = g(0), A = g(0), j = g(1), M = g(!!x.initialMuted), N = g(Number(x.initialRate) > 0 ? Number(x.initialRate) : 1), P = g(!0), F = g(null), I = g(null), L = g(null), R = g(!1), z = g(!0), B = g(""), V = null, H = 0, U = f(() => x.fps && x.fps > 0 ? x.fps : null), be = f(() => D(O.value, U.value)), xe = f(() => ae(k.value, U.value, x.frameCount)), Se = f(() => k.value > 0 ? O.value / k.value * 100 : 0), Ce = f(() => k.value > 0 ? A.value / k.value * 100 : 0), W = f(() => oe({
|
||||
a: F.value,
|
||||
b: I.value
|
||||
}, k.value)), we = f(() => {
|
||||
if (!W.value || k.value <= 0) return null;
|
||||
let e = W.value.a / k.value * 100, t = (W.value.b - W.value.a) / k.value * 100;
|
||||
return {
|
||||
left: `${e}%`,
|
||||
width: `${t}%`
|
||||
};
|
||||
}), G = f(() => L.value == null || k.value <= 0 ? null : L.value / 100 * k.value);
|
||||
function K() {
|
||||
return S.value;
|
||||
}
|
||||
function Te() {
|
||||
let e = K();
|
||||
if (e) {
|
||||
O.value = e.currentTime || 0;
|
||||
let t = se(O.value, {
|
||||
a: F.value,
|
||||
b: I.value
|
||||
}, k.value);
|
||||
t != null && T.value && (e.currentTime = t, O.value = t);
|
||||
}
|
||||
T.value && (H = requestAnimationFrame(Te));
|
||||
}
|
||||
function Ee() {
|
||||
T.value = !0, cancelAnimationFrame(H), H = requestAnimationFrame(Te), Ge();
|
||||
}
|
||||
function De() {
|
||||
T.value = !1, cancelAnimationFrame(H), We();
|
||||
}
|
||||
function Oe() {
|
||||
let e = K();
|
||||
k.value = Number.isFinite(e?.duration) ? Number(e?.duration) : 0, B.value = "", e && (e.volume = j.value, e.muted = M.value, e.playbackRate = N.value);
|
||||
}
|
||||
function q() {
|
||||
let e = K();
|
||||
if (e) {
|
||||
T.value || (O.value = e.currentTime || 0);
|
||||
try {
|
||||
let t = e.buffered;
|
||||
A.value = t.length ? t.end(t.length - 1) : 0;
|
||||
} catch {
|
||||
A.value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
function ke() {
|
||||
if (P.value && !W.value) {
|
||||
let e = K();
|
||||
e && (e.currentTime = 0, e.play()?.catch?.(() => {}));
|
||||
} else re("ended");
|
||||
}
|
||||
function Ae() {
|
||||
B.value = e("viewer.studio.mediaError", "This file cannot be played by the browser.");
|
||||
}
|
||||
function J() {
|
||||
let e = K();
|
||||
e && (e.paused ? e.play()?.catch?.(() => {}) : e.pause());
|
||||
}
|
||||
function Y(e) {
|
||||
let t = K();
|
||||
if (!t || !Number.isFinite(e)) return;
|
||||
let n = k.value > 0 ? k.value : e;
|
||||
t.currentTime = Math.max(0, Math.min(e, n)), O.value = t.currentTime;
|
||||
}
|
||||
function X(e) {
|
||||
let t = K();
|
||||
t && (t.paused || t.pause(), Y(ie(t.currentTime || 0, U.value, e, k.value)));
|
||||
}
|
||||
function je(e) {
|
||||
Y((K()?.currentTime || 0) + e);
|
||||
}
|
||||
function Me(e) {
|
||||
let t = w.value?.getBoundingClientRect();
|
||||
return !t || t.width <= 0 ? 0 : Math.max(0, Math.min(100, (e.clientX - t.left) / t.width * 100));
|
||||
}
|
||||
function Ne(e) {
|
||||
k.value <= 0 || (R.value = !0, e.currentTarget?.setPointerCapture?.(e.pointerId), Y(Me(e) / 100 * k.value));
|
||||
}
|
||||
function Pe(e) {
|
||||
L.value = Me(e), R.value && Y(L.value / 100 * k.value);
|
||||
}
|
||||
function Fe(e) {
|
||||
R.value = !1, e.currentTarget?.releasePointerCapture?.(e.pointerId);
|
||||
}
|
||||
function Ie(e) {
|
||||
N.value = e;
|
||||
let t = K();
|
||||
t && (t.playbackRate = e);
|
||||
}
|
||||
function Le(e = 1, t = !1) {
|
||||
let n = ce, r = n.indexOf(N.value), i = (r < 0 ? n.indexOf(1) : r) + e;
|
||||
t && (i = (i + n.length) % n.length), Ie(n[Math.max(0, Math.min(n.length - 1, i))]);
|
||||
}
|
||||
function Re() {
|
||||
M.value = !M.value;
|
||||
let e = K();
|
||||
e && (e.muted = M.value);
|
||||
}
|
||||
let Z = g(!1);
|
||||
function ze(e) {
|
||||
if (e.type === "pointerdown") Z.value = !0, e.currentTarget?.setPointerCapture?.(e.pointerId);
|
||||
else if (!Z.value) return;
|
||||
let t = e.currentTarget.getBoundingClientRect(), n = t.width > 0 ? (e.clientX - t.left) / t.width : 1;
|
||||
Be(Math.round(Math.max(0, Math.min(1, n)) * 20) / 20);
|
||||
}
|
||||
function Be(e) {
|
||||
j.value = e;
|
||||
let t = K();
|
||||
t && (t.volume = j.value, j.value > 0 && M.value && (M.value = !1, t.muted = !1));
|
||||
}
|
||||
function Ve() {
|
||||
F.value = O.value, I.value != null && I.value <= F.value && (I.value = null);
|
||||
}
|
||||
function He() {
|
||||
I.value = O.value, F.value ??= 0;
|
||||
}
|
||||
function Q() {
|
||||
F.value = null, I.value = null;
|
||||
}
|
||||
function $() {
|
||||
let e = C.value;
|
||||
e && (document.fullscreenElement ? document.exitFullscreen?.() : e.requestFullscreen?.());
|
||||
}
|
||||
async function Ue() {
|
||||
let e = K();
|
||||
if (x.kind !== "video" || !(e instanceof HTMLVideoElement) || !e.videoWidth) return;
|
||||
let t = document.createElement("canvas");
|
||||
t.width = e.videoWidth, t.height = e.videoHeight, t.getContext("2d")?.drawImage(e, 0, 0);
|
||||
let n = await new Promise((e) => t.toBlob(e, "image/png"));
|
||||
if (!n) return;
|
||||
let r = URL.createObjectURL(n), i = String(x.filename || "frame").replace(/\.[^.]+$/, ""), a = document.createElement("a");
|
||||
a.href = r, a.download = `${i}_f${String(be.value).padStart(5, "0")}.png`, a.click(), setTimeout(() => URL.revokeObjectURL(r), 1e3);
|
||||
}
|
||||
function We() {
|
||||
z.value = !0, Ge();
|
||||
}
|
||||
function Ge() {
|
||||
V && clearTimeout(V), V = setTimeout(() => {
|
||||
T.value && !R.value && x.kind === "video" && (z.value = !1);
|
||||
}, 2200);
|
||||
}
|
||||
function Ke() {
|
||||
let e = K();
|
||||
if (e) try {
|
||||
e.pause(), e.removeAttribute("src"), e.load();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}
|
||||
te(() => x.src, () => {
|
||||
Q(), O.value = 0, k.value = 0, A.value = 0, B.value = "";
|
||||
});
|
||||
function qe() {
|
||||
let e = K();
|
||||
if (!e) return;
|
||||
e.muted = M.value, e.playbackRate = N.value;
|
||||
let t = e.play?.();
|
||||
t && typeof t.catch == "function" && t.catch(() => {
|
||||
e.muted || (M.value = !0, e.muted = !0, e.play?.()?.catch?.(() => {}));
|
||||
});
|
||||
}
|
||||
return i(qe), ne(() => {
|
||||
cancelAnimationFrame(H), V && clearTimeout(V), Ke();
|
||||
}), n({
|
||||
togglePlay: J,
|
||||
play: qe,
|
||||
stepFrame: X,
|
||||
seekBy: je,
|
||||
cycleRate: Le,
|
||||
toggleMute: Re,
|
||||
setLoopA: Ve,
|
||||
setLoopB: He,
|
||||
clearLoop: Q,
|
||||
toggleFullscreen: $,
|
||||
exportFrame: Ue,
|
||||
release: Ke
|
||||
}), (n, i) => {
|
||||
let u = l("MButton");
|
||||
return c(), v("div", {
|
||||
ref_key: "root",
|
||||
ref: C,
|
||||
class: m(["mjr-studio-player", {
|
||||
"is-audio": t.kind === "audio",
|
||||
"is-compact": t.compact,
|
||||
"controls-hidden": !z.value
|
||||
}]),
|
||||
onPointermove: We
|
||||
}, [
|
||||
t.kind === "video" ? (c(), v("video", {
|
||||
key: 0,
|
||||
ref_key: "media",
|
||||
ref: S,
|
||||
class: "mjr-studio-player__media",
|
||||
src: t.src,
|
||||
playsinline: "",
|
||||
preload: "auto",
|
||||
onClick: J,
|
||||
onDblclick: $,
|
||||
onPlay: Ee,
|
||||
onPause: De,
|
||||
onLoadedmetadata: Oe,
|
||||
onTimeupdate: q,
|
||||
onProgress: q,
|
||||
onEnded: ke,
|
||||
onError: Ae
|
||||
}, null, 40, le)) : (c(), v("div", {
|
||||
key: 1,
|
||||
class: "mjr-studio-player__audio-art",
|
||||
onClick: J
|
||||
}, [
|
||||
i[7] ||= s("i", {
|
||||
class: "pi pi-volume-up",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1),
|
||||
s("span", null, y(t.filename), 1),
|
||||
s("audio", {
|
||||
ref_key: "media",
|
||||
ref: S,
|
||||
src: t.src,
|
||||
preload: "auto",
|
||||
onPlay: Ee,
|
||||
onPause: De,
|
||||
onLoadedmetadata: Oe,
|
||||
onTimeupdate: q,
|
||||
onProgress: q,
|
||||
onEnded: ke,
|
||||
onError: Ae
|
||||
}, null, 40, ue)
|
||||
])),
|
||||
!T.value && !B.value ? (c(), o(u, {
|
||||
key: 2,
|
||||
type: "button",
|
||||
class: "mjr-studio-player__big-play",
|
||||
"aria-label": b(e)("viewer.studio.play", "Play"),
|
||||
onClick: J
|
||||
}, {
|
||||
default: p(() => [...i[8] ||= [s("i", {
|
||||
class: "pi pi-play",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["aria-label"])) : d("", !0),
|
||||
B.value ? (c(), v("div", de, y(B.value), 1)) : d("", !0),
|
||||
s("div", {
|
||||
class: "mjr-studio-player__controls",
|
||||
onPointerdown: i[6] ||= ee(() => {}, ["stop"])
|
||||
}, [s("div", {
|
||||
ref_key: "track",
|
||||
ref: w,
|
||||
class: "mjr-studio-player__track",
|
||||
role: "slider",
|
||||
"aria-label": b(e)("viewer.studio.seek", "Seek"),
|
||||
"aria-valuemin": 0,
|
||||
"aria-valuemax": k.value,
|
||||
"aria-valuenow": O.value,
|
||||
onPointerdown: Ne,
|
||||
onPointermove: Pe,
|
||||
onPointerup: Fe,
|
||||
onPointerleave: i[0] ||= (e) => L.value = null
|
||||
}, [
|
||||
s("div", {
|
||||
class: "mjr-studio-player__buffered",
|
||||
style: h({ width: `${Ce.value}%` })
|
||||
}, null, 4),
|
||||
we.value ? (c(), v("div", {
|
||||
key: 0,
|
||||
class: "mjr-studio-player__loop",
|
||||
style: h(we.value)
|
||||
}, null, 4)) : d("", !0),
|
||||
s("div", {
|
||||
class: "mjr-studio-player__progress",
|
||||
style: h({ width: `${Se.value}%` })
|
||||
}, null, 4),
|
||||
s("div", {
|
||||
class: "mjr-studio-player__thumb",
|
||||
style: h({ left: `${Se.value}%` })
|
||||
}, null, 4),
|
||||
G.value == null ? d("", !0) : (c(), v("div", {
|
||||
key: 1,
|
||||
class: "mjr-studio-player__hover",
|
||||
style: h({ left: `${L.value}%` })
|
||||
}, [r(y(b(E)(G.value)), 1), U.value ? (c(), v(a, { key: 0 }, [r(" · f" + y(b(D)(G.value, U.value)), 1)], 64)) : d("", !0)], 4))
|
||||
], 40, fe), s("div", pe, [
|
||||
_(u, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: b(e)("viewer.studio.playPause", "Play / pause (Space)"),
|
||||
onClick: J
|
||||
}, {
|
||||
default: p(() => [s("i", {
|
||||
class: m(T.value ? "pi pi-pause" : "pi pi-play"),
|
||||
"aria-hidden": "true"
|
||||
}, null, 2)]),
|
||||
_: 1
|
||||
}, 8, ["title"]),
|
||||
t.kind === "video" ? (c(), v(a, { key: 0 }, [_(u, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: b(e)("viewer.studio.prevFrame", "Previous frame (,)"),
|
||||
onClick: i[1] ||= (e) => X(-1)
|
||||
}, {
|
||||
default: p(() => [...i[9] ||= [s("i", {
|
||||
class: "pi pi-step-backward",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"]), _(u, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: b(e)("viewer.studio.nextFrame", "Next frame (.)"),
|
||||
onClick: i[2] ||= (e) => X(1)
|
||||
}, {
|
||||
default: p(() => [...i[10] ||= [s("i", {
|
||||
class: "pi pi-step-forward",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"])], 64)) : d("", !0),
|
||||
s("span", me, [r(y(b(E)(O.value)) + " ", 1), s("span", he, "/ " + y(b(E)(k.value)), 1)]),
|
||||
t.kind === "video" && U.value ? (c(), v("span", {
|
||||
key: 1,
|
||||
class: "mjr-studio-chip",
|
||||
title: b(e)("viewer.studio.frame", "Frame")
|
||||
}, [
|
||||
r(" f " + y(be.value), 1),
|
||||
xe.value ? (c(), v(a, { key: 0 }, [r(" / " + y(xe.value - 1), 1)], 64)) : d("", !0),
|
||||
r(" · " + y(Math.round(U.value * 100) / 100) + " fps ", 1)
|
||||
], 8, ge)) : d("", !0),
|
||||
i[17] ||= s("span", { class: "mjr-studio-player__spacer" }, null, -1),
|
||||
s("div", {
|
||||
class: "mjr-studio-player__group",
|
||||
title: b(e)("viewer.studio.abLoop", "A-B loop (I / O, U to clear)")
|
||||
}, [
|
||||
_(u, {
|
||||
type: "button",
|
||||
class: m(["mjr-studio-btn is-text", { "is-on": F.value != null }]),
|
||||
onClick: Ve
|
||||
}, {
|
||||
default: p(() => [...i[11] ||= [r("A", -1)]]),
|
||||
_: 1
|
||||
}, 8, ["class"]),
|
||||
_(u, {
|
||||
type: "button",
|
||||
class: m(["mjr-studio-btn is-text", { "is-on": I.value != null }]),
|
||||
onClick: He
|
||||
}, {
|
||||
default: p(() => [...i[12] ||= [r("B", -1)]]),
|
||||
_: 1
|
||||
}, 8, ["class"]),
|
||||
F.value != null || I.value != null ? (c(), o(u, {
|
||||
key: 0,
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: b(e)("viewer.studio.clearLoop", "Clear A-B loop"),
|
||||
onClick: Q
|
||||
}, {
|
||||
default: p(() => [...i[13] ||= [s("i", {
|
||||
class: "pi pi-times",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"])) : d("", !0)
|
||||
], 8, _e),
|
||||
_(u, {
|
||||
type: "button",
|
||||
class: m(["mjr-studio-btn", { "is-on": P.value }]),
|
||||
title: b(e)("viewer.studio.loop", "Loop"),
|
||||
onClick: i[3] ||= (e) => P.value = !P.value
|
||||
}, {
|
||||
default: p(() => [...i[14] ||= [s("i", {
|
||||
class: "pi pi-replay",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["class", "title"]),
|
||||
_(u, {
|
||||
type: "button",
|
||||
class: m(["mjr-studio-btn is-text", { "is-on": N.value !== 1 }]),
|
||||
title: b(e)("viewer.studio.speed", "Speed ([ / ])"),
|
||||
onClick: i[4] ||= (e) => Le(1, !0)
|
||||
}, {
|
||||
default: p(() => [r(y(N.value) + "×", 1)]),
|
||||
_: 1
|
||||
}, 8, ["class", "title"]),
|
||||
s("div", ve, [_(u, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: b(e)("viewer.studio.mute", "Mute (M)"),
|
||||
onClick: Re
|
||||
}, {
|
||||
default: p(() => [s("i", {
|
||||
class: m(M.value || j.value === 0 ? "pi pi-volume-off" : "pi pi-volume-up"),
|
||||
"aria-hidden": "true"
|
||||
}, null, 2)]),
|
||||
_: 1
|
||||
}, 8, ["title"]), s("div", {
|
||||
class: "mjr-studio-player__volume",
|
||||
role: "slider",
|
||||
"aria-label": b(e)("viewer.studio.volume", "Volume"),
|
||||
"aria-valuemin": 0,
|
||||
"aria-valuemax": 1,
|
||||
"aria-valuenow": M.value ? 0 : j.value,
|
||||
onPointerdown: ze,
|
||||
onPointermove: ze,
|
||||
onPointerup: i[5] ||= (e) => Z.value = !1
|
||||
}, [s("div", {
|
||||
class: "mjr-studio-player__volume-fill",
|
||||
style: h({ width: `${(M.value ? 0 : j.value) * 100}%` })
|
||||
}, null, 4)], 40, ye)]),
|
||||
t.kind === "video" ? (c(), o(u, {
|
||||
key: 2,
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: b(e)("viewer.studio.exportFrame", "Save this frame as PNG"),
|
||||
onClick: Ue
|
||||
}, {
|
||||
default: p(() => [...i[15] ||= [s("i", {
|
||||
class: "pi pi-camera",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"])) : d("", !0),
|
||||
t.kind === "video" ? (c(), o(u, {
|
||||
key: 3,
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: b(e)("viewer.studio.fullscreen", "Fullscreen (F)"),
|
||||
onClick: $
|
||||
}, {
|
||||
default: p(() => [...i[16] ||= [s("i", {
|
||||
class: "pi pi-window-maximize",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"])) : d("", !0)
|
||||
])], 32)
|
||||
], 34);
|
||||
};
|
||||
}
|
||||
});
|
||||
export { T as a, k as i, A as n, M as r, N as t };
|
||||
Vendored
+1002
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { $t as e, F as t, P as n, v as r } from "./viewerRuntimeHosts-CW2DfHq_.js";
|
||||
import { m as i, n as a, t as o, wt as ee } from "./events-cBMXVULQ.js";
|
||||
import { Z as s } from "./viewerOpenRequest-_DqD1rXA.js";
|
||||
import { A as c, B as l, C as u, E as d, H as f, K as p, N as m, R as h, T as g, W as _, X as v, Y as y, b, dt as x, ft as S, it as C, j as w, k as T, pt as E, q as D, ut as O, z as k } from "./mjr-primevue-3mpaVb1C.js";
|
||||
import { $t as e, F as t, P as n, v as r } from "./viewerRuntimeHosts-XPTU8ZhU.js";
|
||||
import { m as i, n as a, t as o, wt as s } from "./events-CttACGx4.js";
|
||||
import { Z as c } from "./viewerOpenRequest-CZXk8yvU.js";
|
||||
import { $ as l, B as u, C as d, E as f, H as p, J as m, K as h, M as g, P as _, Q as v, T as y, V as ee, W as b, X as x, b as S, gt as C, ht as w, j as T, k as E, mt as D, pt as O, st as k } from "./mjr-primevue-C07IsEdU.js";
|
||||
// ui/vue/components/common/RatingEditor.vue?vue&type=script&setup=true&lang.ts
|
||||
var A = [
|
||||
"aria-busy",
|
||||
@@ -9,7 +9,7 @@ var A = [
|
||||
"aria-label",
|
||||
"aria-valuenow",
|
||||
"tabindex"
|
||||
], j = /* @__PURE__ */ m({
|
||||
], j = /* @__PURE__ */ _({
|
||||
__name: "RatingEditor",
|
||||
props: {
|
||||
asset: {},
|
||||
@@ -22,21 +22,21 @@ var A = [
|
||||
function a(e) {
|
||||
return Math.max(0, Math.min(5, Number(e) || 0));
|
||||
}
|
||||
let l = t, d = r, m = a(l.asset?.rating ?? l.modelValue), h = C(m), v = C(0), b = C(!1), x = m, E = m, j = null, M = g(() => v.value > 0 ? v.value : h.value), te = (e) => e <= M.value ? "#FFD45A" : "#555", ne = (e) => e <= M.value ? "scale(1.1)" : "scale(1)", re = g(() => {
|
||||
let l = t, u = r, f = a(l.asset?.rating ?? l.modelValue), p = k(f), _ = k(0), S = k(!1), C = f, D = f, j = null, M = y(() => _.value > 0 ? _.value : p.value), te = (e) => e <= M.value ? "#FFD45A" : "#555", ne = (e) => e <= M.value ? "scale(1.1)" : "scale(1)", re = y(() => {
|
||||
let e = l.size;
|
||||
return typeof e == "number" ? `${Math.max(8, e)}px` : String(e || "").trim() || "16px";
|
||||
}), ie = (e) => Math.min(100 * 2 ** Math.max(0, e - 1), 2e3), ae = (e) => new Promise((t) => setTimeout(t, e));
|
||||
function N(e) {
|
||||
if (b.value) return;
|
||||
if (S.value) return;
|
||||
let t = a(e);
|
||||
x = t, E = t, t !== h.value && (h.value = t);
|
||||
C = t, D = t, t !== p.value && (p.value = t);
|
||||
}
|
||||
function P(t = null) {
|
||||
h.value = x, E = x, d("update:modelValue", x), t && e(t, "error");
|
||||
p.value = C, D = C, u("update:modelValue", C), t && e(t, "error");
|
||||
}
|
||||
async function oe() {
|
||||
if (b.value) return;
|
||||
b.value = !0;
|
||||
if (S.value) return;
|
||||
S.value = !0;
|
||||
let t = Date.now();
|
||||
try {
|
||||
let r = 0;
|
||||
@@ -46,51 +46,51 @@ var A = [
|
||||
break;
|
||||
}
|
||||
r > 0 && await ae(ie(r)), r += 1;
|
||||
let c = a(E), u = typeof AbortController < "u" ? new AbortController() : null;
|
||||
j = u;
|
||||
let f = null;
|
||||
let d = a(D), f = typeof AbortController < "u" ? new AbortController() : null;
|
||||
j = f;
|
||||
let m = null;
|
||||
try {
|
||||
f = await n(l.asset, c, u ? { signal: u.signal } : {});
|
||||
m = await n(l.asset, d, f ? { signal: f.signal } : {});
|
||||
} catch (e) {
|
||||
console.error("Failed to update rating:", e);
|
||||
}
|
||||
if (!f?.ok) {
|
||||
if (f?.code === "ABORTED" && E !== c) continue;
|
||||
P(f?.error || i("toast.ratingUpdateFailed", "Rating update failed"));
|
||||
if (!m?.ok) {
|
||||
if (m?.code === "ABORTED" && D !== d) continue;
|
||||
P(m?.error || i("toast.ratingUpdateFailed", "Rating update failed"));
|
||||
break;
|
||||
}
|
||||
try {
|
||||
let e = f?.data?.asset_id ?? null;
|
||||
e != null && !ee(l.asset.id) && (l.asset.id = e);
|
||||
let e = m?.data?.asset_id ?? null;
|
||||
e != null && !s(l.asset.id) && (l.asset.id = e);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
x = c, h.value = c;
|
||||
C = d, p.value = d;
|
||||
try {
|
||||
l.asset.rating = c;
|
||||
l.asset.rating = d;
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
if (d("update:modelValue", c), d("rating-change", {
|
||||
if (u("update:modelValue", d), u("rating-change", {
|
||||
assetId: l.asset?.id,
|
||||
rating: c
|
||||
}), s(o, {
|
||||
rating: d
|
||||
}), c(o, {
|
||||
assetId: l.asset?.id == null ? "" : String(l.asset.id),
|
||||
rating: c
|
||||
}), E === c) {
|
||||
e(i("toast.ratingSetN", { n: c }), "success", 1e3);
|
||||
rating: d
|
||||
}), D === d) {
|
||||
e(i("toast.ratingSetN", { n: d }), "success", 1e3);
|
||||
break;
|
||||
}
|
||||
}
|
||||
r >= 10 && P(i("toast.ratingUpdateFailed", "Rating update failed"));
|
||||
} finally {
|
||||
j = null, b.value = !1;
|
||||
j = null, S.value = !1;
|
||||
}
|
||||
}
|
||||
function F(e) {
|
||||
if (l.disabled) return;
|
||||
let t = a(e);
|
||||
E = t, h.value = t, v.value = 0, d("update:modelValue", t);
|
||||
D = t, p.value = t, _.value = 0, u("update:modelValue", t);
|
||||
try {
|
||||
j?.abort?.();
|
||||
} catch (e) {
|
||||
@@ -102,10 +102,10 @@ var A = [
|
||||
F(e);
|
||||
}
|
||||
function L(e) {
|
||||
l.disabled || (v.value = e);
|
||||
l.disabled || (_.value = e);
|
||||
}
|
||||
function R() {
|
||||
v.value = 0;
|
||||
_.value = 0;
|
||||
}
|
||||
function z(e) {
|
||||
if (l.disabled) return;
|
||||
@@ -113,11 +113,11 @@ var A = [
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowUp":
|
||||
e.preventDefault(), t = Math.min(5, h.value + 1);
|
||||
e.preventDefault(), t = Math.min(5, p.value + 1);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "ArrowDown":
|
||||
e.preventDefault(), t = Math.max(0, h.value - 1);
|
||||
e.preventDefault(), t = Math.max(0, p.value - 1);
|
||||
break;
|
||||
case "Home":
|
||||
e.preventDefault(), t = 0;
|
||||
@@ -127,45 +127,45 @@ var A = [
|
||||
break;
|
||||
default: e.key >= "0" && e.key <= "5" && (e.preventDefault(), t = parseInt(e.key, 10));
|
||||
}
|
||||
t !== null && t !== h.value && F(t);
|
||||
t !== null && t !== p.value && F(t);
|
||||
}
|
||||
return k(() => {
|
||||
return ee(() => {
|
||||
try {
|
||||
j?.abort?.();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}), D(() => l.modelValue, (e) => {
|
||||
}), x(() => l.modelValue, (e) => {
|
||||
N(e);
|
||||
}), D(() => l.asset?.rating, (e) => {
|
||||
}), x(() => l.asset?.rating, (e) => {
|
||||
N(e);
|
||||
}), (e, n) => {
|
||||
let r = p("MButton");
|
||||
return f(), T("div", {
|
||||
let r = m("MButton");
|
||||
return b(), E("div", {
|
||||
class: "mjr-rating-editor",
|
||||
style: S({
|
||||
style: w({
|
||||
display: "inline-flex",
|
||||
gap: "2px",
|
||||
pointerEvents: t.disabled ? "none" : "auto",
|
||||
opacity: t.disabled ? .6 : 1
|
||||
}),
|
||||
"aria-busy": b.value,
|
||||
"aria-busy": S.value,
|
||||
"aria-disabled": t.disabled,
|
||||
role: "slider",
|
||||
"aria-label": O(i)("rating.label", "Rating"),
|
||||
"aria-valuemin": 0,
|
||||
"aria-valuemax": 5,
|
||||
"aria-valuenow": h.value,
|
||||
"aria-valuenow": p.value,
|
||||
tabindex: t.disabled ? -1 : 0,
|
||||
onKeydown: z
|
||||
}, [(f(), T(u, null, _(5, (e) => w(r, {
|
||||
}, [(b(), E(d, null, h(5, (e) => g(r, {
|
||||
key: e,
|
||||
type: "button",
|
||||
class: "mjr-rating-star",
|
||||
severity: "secondary",
|
||||
text: "",
|
||||
rounded: "",
|
||||
style: S({
|
||||
style: w({
|
||||
color: te(e),
|
||||
transform: ne(e),
|
||||
fontSize: re.value,
|
||||
@@ -176,13 +176,13 @@ var A = [
|
||||
transition: "all 0.15s ease"
|
||||
}),
|
||||
"aria-label": O(i)("rating.setN", { n: e }),
|
||||
"aria-pressed": e <= h.value,
|
||||
"aria-pressed": e <= p.value,
|
||||
disabled: t.disabled,
|
||||
onClick: (t) => I(e),
|
||||
onMouseenter: (t) => L(e),
|
||||
onMouseleave: R
|
||||
}, {
|
||||
default: y(() => [...n[0] ||= [c(" ★ ", -1)]]),
|
||||
default: v(() => [...n[0] ||= [T(" ★ ", -1)]]),
|
||||
_: 1
|
||||
}, 8, [
|
||||
"style",
|
||||
@@ -197,16 +197,23 @@ var A = [
|
||||
}), M = ["aria-busy"], te = ["aria-label"], ne = {
|
||||
key: 0,
|
||||
class: "mjr-tags-empty"
|
||||
}, re = { class: "mjr-tags-input-wrap" }, ie = ["aria-selected", "onMouseenter"], ae = 100, N = 200, P = /* @__PURE__ */ m({
|
||||
}, re = { class: "mjr-tags-input-wrap" }, ie = ["aria-selected", "onMouseenter"], ae = 100, N = 200, P = /* @__PURE__ */ _({
|
||||
__name: "TagsEditor",
|
||||
props: {
|
||||
asset: {},
|
||||
modelValue: {},
|
||||
disabled: { type: Boolean }
|
||||
modelValue: { default: void 0 },
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: !1
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: !0
|
||||
}
|
||||
},
|
||||
emits: ["update:modelValue", "tags-change"],
|
||||
setup(n, { emit: o }) {
|
||||
function m(e) {
|
||||
function _(e) {
|
||||
try {
|
||||
let t = String(e ?? "").trim();
|
||||
if (!t || t.length > ae) return null;
|
||||
@@ -219,7 +226,7 @@ var A = [
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function S(e) {
|
||||
function w(e) {
|
||||
try {
|
||||
return String(e ?? "").replace(/[\x00-\x1f\x7f]/g, "").trim() || null;
|
||||
} catch {
|
||||
@@ -227,13 +234,13 @@ var A = [
|
||||
}
|
||||
}
|
||||
function A(e) {
|
||||
let t = S(e);
|
||||
let t = w(e);
|
||||
return t ? t.toLowerCase() : "";
|
||||
}
|
||||
function j(e) {
|
||||
let t = [], n = /* @__PURE__ */ new Set();
|
||||
for (let r of Array.isArray(e) ? e : []) {
|
||||
let e = S(r);
|
||||
let e = w(r);
|
||||
if (!e) continue;
|
||||
let i = e.toLowerCase();
|
||||
if (!n.has(i) && (n.add(i), t.push(e), t.length >= N)) break;
|
||||
@@ -259,7 +266,7 @@ var A = [
|
||||
for (let n = 0; n < e.length; n += 1) if (e[n] !== t[n]) return !1;
|
||||
return !0;
|
||||
}
|
||||
let F = n, I = o, L = P(F.asset?.tags ?? F.modelValue ?? []), R = C([...L]), z = C(null), B = C(""), V = C(!1), H = C(-1), U = C(null), W = C([]), G = C(!1), K = (e) => e?.$el || e || null, q = !1, J = !1, Y = null, X = [...L], se = (e) => Math.min(100 * 2 ** Math.max(0, e - 1), 2e3), ce = (e) => new Promise((t) => setTimeout(t, e)), le = () => setTimeout(() => V.value = !1, 200), Z = g(() => {
|
||||
let F = n, I = o, L = P(F.asset?.tags ?? F.modelValue ?? []), R = k([...L]), z = k(null), B = k(""), V = k(!1), H = k(-1), U = k(null), W = k([]), G = k(!1), K = (e) => e?.$el || e || null, q = !1, J = !1, Y = null, X = [...L], se = (e) => Math.min(100 * 2 ** Math.max(0, e - 1), 2e3), ce = (e) => new Promise((t) => setTimeout(t, e)), le = () => setTimeout(() => V.value = !1, 200), Z = y(() => {
|
||||
let e = B.value.toLowerCase().trim();
|
||||
if (!e) return [];
|
||||
let t = new Set(R.value.map((e) => A(e)).filter(Boolean));
|
||||
@@ -267,7 +274,7 @@ var A = [
|
||||
let r = A(n);
|
||||
return n.toLowerCase().includes(e) && (!r || !t.has(r));
|
||||
}).slice(0, 10);
|
||||
}), Q = g(() => Z.value.length > 0);
|
||||
}), Q = y(() => Z.value.length > 0);
|
||||
function ue(e) {
|
||||
if (q) return;
|
||||
let t = P(e);
|
||||
@@ -278,7 +285,7 @@ var A = [
|
||||
I("update:modelValue", t), I("tags-change", {
|
||||
assetId: F.asset?.id,
|
||||
tags: t
|
||||
}), s(a, {
|
||||
}), c(a, {
|
||||
assetId: n,
|
||||
tags: t
|
||||
});
|
||||
@@ -320,18 +327,18 @@ var A = [
|
||||
}
|
||||
try {
|
||||
let e = o?.data?.asset_id ?? null;
|
||||
e != null && !ee(F.asset.id) && (F.asset.id = e);
|
||||
e != null && !s(F.asset.id) && (F.asset.id = e);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
let s = P(Array.isArray(o?.data?.tags) ? o.data.tags : r);
|
||||
X = [...s];
|
||||
let c = P(Array.isArray(o?.data?.tags) ? o.data.tags : r);
|
||||
X = [...c];
|
||||
try {
|
||||
F.asset.tags = [...s];
|
||||
F.asset.tags = [...c];
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
if (J || (R.value = [...s]), de(s), e(i("toast.tagsUpdated", "Tags updated"), "success", 1e3), !J) break;
|
||||
if (J || (R.value = [...c]), de(c), e(i("toast.tagsUpdated", "Tags updated"), "success", 1e3), !J) break;
|
||||
J = !1;
|
||||
}
|
||||
if (n >= 10) {
|
||||
@@ -346,9 +353,9 @@ var A = [
|
||||
}
|
||||
q = !1, Y = null, G.value = !1;
|
||||
}
|
||||
l(async () => {
|
||||
p(async () => {
|
||||
try {
|
||||
await h(), K(z.value)?.focus();
|
||||
await u(), F.autofocus && K(z.value)?.focus();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
@@ -358,7 +365,7 @@ var A = [
|
||||
} catch (e) {
|
||||
console.warn("Failed to load available tags:", e);
|
||||
}
|
||||
}), k(() => {
|
||||
}), ee(() => {
|
||||
try {
|
||||
Y?.abort?.();
|
||||
} catch (e) {
|
||||
@@ -367,7 +374,7 @@ var A = [
|
||||
});
|
||||
function $(t) {
|
||||
if (F.disabled) return;
|
||||
let n = m(t);
|
||||
let n = _(t);
|
||||
if (!n) return;
|
||||
let r = A(n);
|
||||
r && (R.value.some((e) => A(e) === r) || (R.value.length >= N ? e(i("toast.maxTagsReached", "Maximum number of tags reached"), "warning") : (R.value = [...R.value, n], fe())));
|
||||
@@ -398,24 +405,24 @@ var A = [
|
||||
let t = e?.value ?? U.value;
|
||||
t && he(t);
|
||||
}
|
||||
return D(() => F.modelValue, (e) => {
|
||||
return x(() => F.modelValue, (e) => {
|
||||
ue(e);
|
||||
}), D(() => F.asset?.tags, (e) => {
|
||||
}), x(() => F.asset?.tags, (e) => {
|
||||
ue(e);
|
||||
}), (e, t) => {
|
||||
let r = p("MButton"), a = p("MInputText"), o = p("MListbox");
|
||||
return f(), T("div", {
|
||||
class: x(["mjr-tags-editor", { "is-disabled": n.disabled }]),
|
||||
let r = m("MButton"), a = m("MInputText"), o = m("MListbox");
|
||||
return b(), E("div", {
|
||||
class: D(["mjr-tags-editor", { "is-disabled": n.disabled }]),
|
||||
"aria-busy": G.value
|
||||
}, [d("div", {
|
||||
}, [f("div", {
|
||||
class: "mjr-tags-display",
|
||||
role: "list",
|
||||
"aria-label": O(i)("tags.label", "Tags")
|
||||
}, [R.value.length === 0 ? (f(), T("span", ne, E(O(i)("msg.noTagsYet", "No tags yet...")), 1)) : (f(!0), T(u, { key: 1 }, _(R.value, (e, a) => (f(), T("div", {
|
||||
}, [R.value.length === 0 ? (b(), E("span", ne, C(O(i)("msg.noTagsYet", "No tags yet...")), 1)) : (b(!0), E(d, { key: 1 }, h(R.value, (e, a) => (b(), E("div", {
|
||||
key: e,
|
||||
class: "mjr-tag-chip",
|
||||
role: "listitem"
|
||||
}, [c(E(e) + " ", 1), w(r, {
|
||||
}, [T(C(e) + " ", 1), g(r, {
|
||||
type: "button",
|
||||
class: "mjr-tag-chip-remove",
|
||||
severity: "secondary",
|
||||
@@ -425,13 +432,13 @@ var A = [
|
||||
disabled: n.disabled,
|
||||
onClick: (e) => pe(a)
|
||||
}, {
|
||||
default: y(() => [...t[4] ||= [c(" x ", -1)]]),
|
||||
default: v(() => [...t[4] ||= [T(" x ", -1)]]),
|
||||
_: 1
|
||||
}, 8, [
|
||||
"aria-label",
|
||||
"disabled",
|
||||
"onClick"
|
||||
])]))), 128))], 8, te), d("div", re, [w(a, {
|
||||
])]))), 128))], 8, te), f("div", re, [g(a, {
|
||||
ref_key: "inputRef",
|
||||
ref: z,
|
||||
modelValue: B.value,
|
||||
@@ -456,7 +463,7 @@ var A = [
|
||||
"aria-autocomplete",
|
||||
"aria-expanded",
|
||||
"aria-haspopup"
|
||||
]), v(w(o, {
|
||||
]), l(g(o, {
|
||||
modelValue: U.value,
|
||||
"onUpdate:modelValue": t[3] ||= (e) => U.value = e,
|
||||
options: Z.value,
|
||||
@@ -465,17 +472,17 @@ var A = [
|
||||
"aria-label": O(i)("tags.suggestions", "Tag suggestions"),
|
||||
onChange: ge
|
||||
}, {
|
||||
option: y(({ option: e, index: t }) => [d("div", {
|
||||
class: x(["mjr-tag-suggestion", { "is-active": t === H.value }]),
|
||||
option: v(({ option: e, index: t }) => [f("div", {
|
||||
class: D(["mjr-tag-suggestion", { "is-active": t === H.value }]),
|
||||
"aria-selected": t === H.value,
|
||||
onMouseenter: (e) => H.value = t
|
||||
}, E(e), 43, ie)]),
|
||||
}, C(e), 43, ie)]),
|
||||
_: 1
|
||||
}, 8, [
|
||||
"modelValue",
|
||||
"options",
|
||||
"aria-label"
|
||||
]), [[b, V.value && Q.value]])])], 10, M);
|
||||
]), [[S, V.value && Q.value]])])], 10, M);
|
||||
};
|
||||
}
|
||||
});
|
||||
Vendored
+2287
File diff suppressed because it is too large
Load Diff
Vendored
-917
@@ -1,917 +0,0 @@
|
||||
import { $t as e, P as t, a as n, g as r, i, x as a, y as o } from "./viewerRuntimeHosts-CW2DfHq_.js";
|
||||
import { at as s, m as c, mt as l, r as u, t as d } from "./events-cBMXVULQ.js";
|
||||
import { c as f, d as p, f as m, l as h, o as g, r as _, s as v, u as y } from "./viewerOpenRequest-_DqD1rXA.js";
|
||||
import { i as b, n as x, t as S, tt as C } from "./SidebarWorkflowSection-F3hoMYG1.js";
|
||||
import { i as w, r as T } from "./floatingViewerManager-CKeu6wKp.js";
|
||||
import { A as E, B as D, C as O, D as k, E as A, H as j, K as ee, N as M, O as N, R as te, S as ne, T as P, V as F, W as I, X as L, Y as R, b as z, dt as B, ft as V, it as H, j as U, k as W, ot as re, pt as G, q as K, rt as ie, ut as q, w as ae, z as oe } from "./mjr-primevue-3mpaVb1C.js";
|
||||
import { n as se, t as ce } from "./TagsEditor-qDIeaA8P.js";
|
||||
import { a as J, i as le, n as ue, r as de, t as fe } from "./StudioPlayer-DXSy5UA5.js";
|
||||
import { t as pe } from "./genInfo-B3LeL9TU.js";
|
||||
// ui/vue/components/viewer/FloatingViewerHost.vue
|
||||
var me = /* @__PURE__ */ M({
|
||||
__name: "FloatingViewerHost",
|
||||
setup(e) {
|
||||
let t = H(null), n = null;
|
||||
return D(() => {
|
||||
n = i(t.value);
|
||||
}), F(() => {
|
||||
n?.(), n = null;
|
||||
}), (e, n) => (j(), W("div", {
|
||||
ref_key: "hostRef",
|
||||
ref: t,
|
||||
class: "mjr-viewer-runtime-host mjr-viewer-runtime-host--floating",
|
||||
style: {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
"pointer-events": "none",
|
||||
overflow: "visible"
|
||||
}
|
||||
}, null, 512));
|
||||
}
|
||||
}), he = /* @__PURE__ */ M({
|
||||
__name: "ViewerOverlayHost",
|
||||
setup(e) {
|
||||
let t = H(null), r = null;
|
||||
return D(() => {
|
||||
r = n(t.value);
|
||||
}), F(() => {
|
||||
r?.(), r = null;
|
||||
}), (e, n) => (j(), W("div", {
|
||||
ref_key: "hostRef",
|
||||
ref: t,
|
||||
class: "mjr-viewer-runtime-host mjr-viewer-runtime-host--main",
|
||||
style: {
|
||||
position: "fixed",
|
||||
inset: "0",
|
||||
"pointer-events": "none",
|
||||
overflow: "visible"
|
||||
}
|
||||
}, null, 512));
|
||||
}
|
||||
}), ge = {
|
||||
key: 0,
|
||||
class: "mjr-context-menu-separator"
|
||||
}, _e = { class: "mjr-context-menu-item-left" }, ve = { class: "mjr-context-menu-item-right" }, ye = {
|
||||
key: 0,
|
||||
class: "mjr-context-menu-hint"
|
||||
}, Y = {
|
||||
key: 1,
|
||||
class: "mjr-context-menu-submenu-arrow"
|
||||
}, be = {
|
||||
key: 0,
|
||||
class: "mjr-context-menu-separator"
|
||||
}, xe = { class: "mjr-context-menu-item-left" }, Se = {
|
||||
key: 0,
|
||||
class: "mjr-context-menu-hint"
|
||||
}, Ce = /* @__PURE__ */ M({
|
||||
__name: "ViewerContextMenu",
|
||||
setup(e) {
|
||||
let t = H(null), n = H(null), r = H(null), i = null, a = null, o = P(() => l(m.main, 10041)), s = P(() => l(m.submenu, 10042)), c = P(() => l(m.tags, 10043));
|
||||
function l(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 u() {
|
||||
i &&= (clearTimeout(i), null);
|
||||
}
|
||||
function d() {
|
||||
u(), i = setTimeout(() => {
|
||||
f();
|
||||
}, 180);
|
||||
}
|
||||
function p(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 h(e, t) {
|
||||
await te(), p(e, t?.value || null);
|
||||
}
|
||||
function g(e) {
|
||||
try {
|
||||
e?.value?.querySelector?.(".mjr-context-menu-item:not([aria-disabled=\"true\"])")?.focus?.();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}
|
||||
function _(e, t) {
|
||||
if (!Array.isArray(e?.submenu) || !e.submenu.length) {
|
||||
f();
|
||||
return;
|
||||
}
|
||||
u();
|
||||
let n = (t?.currentTarget)?.getBoundingClientRect?.();
|
||||
y({
|
||||
x: Math.round((n?.right || m.main.x || 0) + 6),
|
||||
y: Math.round((n?.top || m.main.y || 0) - 4),
|
||||
items: e.submenu,
|
||||
title: e.label || ""
|
||||
});
|
||||
}
|
||||
async function b(e, t, n = "main") {
|
||||
if (e && e.type === "item" && !e.disabled) {
|
||||
if (Array.isArray(e.submenu) && e.submenu.length) _(e, t);
|
||||
else try {
|
||||
await e.action?.();
|
||||
} catch (e) {
|
||||
console.error("[ViewerContextMenu.vue] Action failed:", e);
|
||||
} finally {
|
||||
e.closeOnSelect === !1 ? n === "submenu" && f() : v();
|
||||
}
|
||||
}
|
||||
}
|
||||
function x(e, t) {
|
||||
Array.isArray(e?.submenu) && e.submenu.length ? _(e, t) : f();
|
||||
}
|
||||
function S(e) {
|
||||
Array.isArray(e?.submenu) && e.submenu.length && d();
|
||||
}
|
||||
function C() {
|
||||
u();
|
||||
}
|
||||
function w() {
|
||||
d();
|
||||
}
|
||||
function T(e) {
|
||||
let i = e?.target;
|
||||
t.value?.contains?.(i) || n.value?.contains?.(i) || r.value?.contains?.(i) || v();
|
||||
}
|
||||
function E(e) {
|
||||
e?.key === "Escape" && v();
|
||||
}
|
||||
function M() {
|
||||
v();
|
||||
}
|
||||
function ne(e) {
|
||||
let t = e?.detail;
|
||||
String(t?.source || "") !== "viewer" && v();
|
||||
}
|
||||
function L(e) {
|
||||
let t = m.tags.asset;
|
||||
t && (t.tags = Array.isArray(e) ? [...e] : []);
|
||||
}
|
||||
function z(e) {
|
||||
let t = Array.isArray(e?.tags) ? e.tags : [];
|
||||
try {
|
||||
m.tags.onChanged?.(t);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}
|
||||
return K(() => m.main.open, async (e) => {
|
||||
e && (await h(m.main, t), g(t));
|
||||
}), K(() => m.submenu.open, async (e) => {
|
||||
e && (await h(m.submenu, n), g(n));
|
||||
}), K(() => m.tags.open, async (e) => {
|
||||
e && await h(m.tags, r);
|
||||
}), D(() => {
|
||||
a = new AbortController();
|
||||
let e = {
|
||||
capture: !0,
|
||||
passive: !0,
|
||||
signal: a.signal
|
||||
};
|
||||
window.addEventListener("pointerdown", T, e), window.addEventListener("keydown", E, {
|
||||
capture: !0,
|
||||
signal: a.signal
|
||||
}), window.addEventListener("scroll", M, e), window.addEventListener("wheel", M, e), window.addEventListener("resize", M, {
|
||||
passive: !0,
|
||||
signal: a.signal
|
||||
}), window.addEventListener("mjr-close-all-menus", ne, { signal: a.signal });
|
||||
}), F(() => {
|
||||
u();
|
||||
try {
|
||||
a?.abort();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
a = null, v();
|
||||
}), (e, i) => {
|
||||
let a = ee("MButton");
|
||||
return j(), k(ae, { to: "body" }, [
|
||||
q(m).main.open ? (j(), W("div", {
|
||||
key: 0,
|
||||
ref_key: "mainMenuRef",
|
||||
ref: t,
|
||||
class: "mjr-viewer-context-menu mjr-context-menu",
|
||||
style: V(o.value),
|
||||
role: "menu",
|
||||
"aria-label": "Viewer context menu"
|
||||
}, [(j(!0), W(O, null, I(q(m).main.items, (e) => (j(), W(O, { key: e.id }, [e.type === "separator" ? (j(), W("div", ge)) : (j(), k(a, {
|
||||
key: 1,
|
||||
type: "button",
|
||||
class: B(["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) => b(e, t),
|
||||
onMouseenter: (t) => x(e, t),
|
||||
onMouseleave: (t) => S(e)
|
||||
}, {
|
||||
default: R(() => [A("span", _e, [e.iconClass ? (j(), W("i", {
|
||||
key: 0,
|
||||
class: B(e.iconClass)
|
||||
}, null, 2)) : N("", !0), A("span", null, G(e.label), 1)]), A("span", ve, [e.rightHint ? (j(), W("span", ye, G(e.rightHint), 1)) : N("", !0), Array.isArray(e.submenu) && e.submenu.length ? (j(), W("span", Y, " > ")) : N("", !0)])]),
|
||||
_: 2
|
||||
}, 1032, [
|
||||
"class",
|
||||
"aria-disabled",
|
||||
"tabindex",
|
||||
"onClick",
|
||||
"onMouseenter",
|
||||
"onMouseleave"
|
||||
]))], 64))), 128))], 4)) : N("", !0),
|
||||
q(m).submenu.open ? (j(), W("div", {
|
||||
key: 1,
|
||||
ref_key: "submenuRef",
|
||||
ref: n,
|
||||
class: "mjr-viewer-rating-submenu mjr-context-menu",
|
||||
style: V(s.value),
|
||||
role: "menu",
|
||||
"aria-label": "Viewer context submenu",
|
||||
onMouseenter: C,
|
||||
onMouseleave: w
|
||||
}, [(j(!0), W(O, null, I(q(m).submenu.items, (e) => (j(), W(O, { key: e.id }, [e.type === "separator" ? (j(), W("div", be)) : (j(), k(a, {
|
||||
key: 1,
|
||||
type: "button",
|
||||
class: B(["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) => b(e, t, "submenu")
|
||||
}, {
|
||||
default: R(() => [A("span", xe, [e.iconClass ? (j(), W("i", {
|
||||
key: 0,
|
||||
class: B(e.iconClass)
|
||||
}, null, 2)) : N("", !0), A("span", null, G(e.label), 1)]), e.rightHint ? (j(), W("span", Se, G(e.rightHint), 1)) : N("", !0)]),
|
||||
_: 2
|
||||
}, 1032, [
|
||||
"class",
|
||||
"aria-disabled",
|
||||
"tabindex",
|
||||
"onClick"
|
||||
]))], 64))), 128))], 36)) : N("", !0),
|
||||
q(m).tags.open && q(m).tags.asset ? (j(), W("div", {
|
||||
key: 2,
|
||||
ref_key: "tagsPopoverRef",
|
||||
ref: r,
|
||||
class: "mjr-viewer-popover",
|
||||
style: V(c.value)
|
||||
}, [U(ce, {
|
||||
asset: q(m).tags.asset,
|
||||
"model-value": q(m).tags.asset?.tags || [],
|
||||
"onUpdate:modelValue": L,
|
||||
onTagsChange: z
|
||||
}, null, 8, ["asset", "model-value"])], 4)) : N("", !0)
|
||||
]);
|
||||
};
|
||||
}
|
||||
}), X = /* @__PURE__ */ M({
|
||||
__name: "ViewerContextMenuPortal",
|
||||
setup(e) {
|
||||
let t = H(""), n = P(() => h(t.value));
|
||||
return D(() => {
|
||||
t.value = g();
|
||||
}), F(() => {
|
||||
p(t.value), t.value = "";
|
||||
}), (e, t) => n.value ? (j(), k(Ce, { key: 0 })) : N("", !0);
|
||||
}
|
||||
}), Z = ie({
|
||||
open: !1,
|
||||
assets: [],
|
||||
index: 0,
|
||||
session: 0
|
||||
});
|
||||
function we(e, t = 0) {
|
||||
let n = (Array.isArray(e) ? e : []).filter(Boolean);
|
||||
return n.length ? (Z.assets = n, Z.index = Math.max(0, Math.min(Number(t) || 0, n.length - 1)), Z.session += 1, Z.open = !0, !0) : !1;
|
||||
}
|
||||
function Te() {
|
||||
Z.open = !1;
|
||||
}
|
||||
function Q(e) {
|
||||
let t = Z.assets.length;
|
||||
t && (Z.index = (Math.floor(e) % t + t) % t);
|
||||
}
|
||||
function $(e) {
|
||||
Q(Z.index + e);
|
||||
}
|
||||
function Ee(e, t, n = "") {
|
||||
let r = String(n || "").trim().toLowerCase();
|
||||
if (r === "ab" || r === "sidebyside") return !1;
|
||||
try {
|
||||
if (C()?.viewer?.classic === !0) return !1;
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
let i = Array.isArray(e) ? e : [], a = i[Math.max(0, Math.min(Number(t) || 0, i.length - 1))];
|
||||
return !!a && J(a) !== "model3d";
|
||||
}
|
||||
// ui/vue/components/viewer/studio/StudioImageStage.vue?vue&type=script&setup=true&lang.ts
|
||||
var De = ["src", "alt"], Oe = {
|
||||
key: 0,
|
||||
class: "mjr-studio-stage__error"
|
||||
}, ke = .05, Ae = 32, je = /* @__PURE__ */ M({
|
||||
__name: "StudioImageStage",
|
||||
props: {
|
||||
src: {},
|
||||
alt: {},
|
||||
checker: { type: Boolean }
|
||||
},
|
||||
emits: ["zoom", "loaded"],
|
||||
setup(e, { expose: t, emit: n }) {
|
||||
let r = e, i = n, a = H(null), o = H({
|
||||
w: 0,
|
||||
h: 0
|
||||
}), s = H(1), c = H({
|
||||
x: 0,
|
||||
y: 0
|
||||
}), l = H(!0), u = H(!1), d = H(!1), f = null, p = null;
|
||||
function m() {
|
||||
let e = a.value?.getBoundingClientRect();
|
||||
return {
|
||||
w: e?.width || 0,
|
||||
h: e?.height || 0
|
||||
};
|
||||
}
|
||||
function h() {
|
||||
let { w: e, h: t } = m();
|
||||
return !o.value.w || !o.value.h || !e || !t ? 1 : Math.min(e / o.value.w, t / o.value.h);
|
||||
}
|
||||
function g() {
|
||||
l.value = !0, s.value = h(), c.value = {
|
||||
x: 0,
|
||||
y: 0
|
||||
}, i("zoom", s.value);
|
||||
}
|
||||
function _(e, t) {
|
||||
let n = Math.max(ke, Math.min(Ae, e)), { w: r, h: a } = m(), o = t ? t.x - r / 2 : 0, u = t ? t.y - a / 2 : 0, d = n / s.value;
|
||||
c.value = {
|
||||
x: o - (o - c.value.x) * d,
|
||||
y: u - (u - c.value.y) * d
|
||||
}, s.value = n, l.value = !1, i("zoom", s.value);
|
||||
}
|
||||
function v(e) {
|
||||
_(s.value * e);
|
||||
}
|
||||
function y() {
|
||||
_(1), c.value = {
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
}
|
||||
function b() {
|
||||
l.value ? y() : g();
|
||||
}
|
||||
function x(e) {
|
||||
e.preventDefault();
|
||||
let t = a.value?.getBoundingClientRect(), n = Math.exp(-e.deltaY * .0015);
|
||||
_(s.value * n, t ? {
|
||||
x: e.clientX - t.left,
|
||||
y: e.clientY - t.top
|
||||
} : void 0);
|
||||
}
|
||||
function S(e) {
|
||||
e.button === 0 && (f = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
ox: c.value.x,
|
||||
oy: c.value.y,
|
||||
id: e.pointerId
|
||||
}, d.value = !0, e.currentTarget?.setPointerCapture?.(e.pointerId));
|
||||
}
|
||||
function C(e) {
|
||||
f && (c.value = {
|
||||
x: f.ox + e.clientX - f.x,
|
||||
y: f.oy + e.clientY - f.y
|
||||
}, Math.abs(e.clientX - f.x) + Math.abs(e.clientY - f.y) > 2 && (l.value = !1));
|
||||
}
|
||||
function w(e) {
|
||||
f && e.currentTarget?.releasePointerCapture?.(f.id), f = null, d.value = !1;
|
||||
}
|
||||
function T(e) {
|
||||
let t = e.target;
|
||||
o.value = {
|
||||
w: t.naturalWidth,
|
||||
h: t.naturalHeight
|
||||
}, u.value = !1, i("loaded", {
|
||||
width: t.naturalWidth,
|
||||
height: t.naturalHeight
|
||||
}), g();
|
||||
}
|
||||
let E = P(() => ({
|
||||
width: `${o.value.w}px`,
|
||||
height: `${o.value.h}px`,
|
||||
transform: `translate(-50%, -50%) translate(${c.value.x}px, ${c.value.y}px) scale(${s.value})`,
|
||||
imageRendering: s.value >= 2 ? "pixelated" : "auto"
|
||||
}));
|
||||
return K(() => r.src, () => {
|
||||
o.value = {
|
||||
w: 0,
|
||||
h: 0
|
||||
}, u.value = !1;
|
||||
}), D(() => {
|
||||
try {
|
||||
p = new ResizeObserver(() => {
|
||||
l.value && g();
|
||||
}), a.value && p.observe(a.value);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}), oe(() => p?.disconnect()), t({
|
||||
zoomBy: v,
|
||||
fit: g,
|
||||
actualSize: y,
|
||||
toggleFitActual: b
|
||||
}), (t, n) => (j(), W("div", {
|
||||
ref_key: "stage",
|
||||
ref: a,
|
||||
class: B(["mjr-studio-stage", {
|
||||
"is-checker": e.checker,
|
||||
"is-dragging": d.value
|
||||
}]),
|
||||
onWheel: x,
|
||||
onPointerdown: S,
|
||||
onPointermove: C,
|
||||
onPointerup: w,
|
||||
onPointercancel: w,
|
||||
onDblclick: b
|
||||
}, [L((j(), W("img", {
|
||||
key: e.src,
|
||||
class: "mjr-studio-stage__img",
|
||||
src: e.src,
|
||||
alt: e.alt || "",
|
||||
draggable: "false",
|
||||
style: V(E.value),
|
||||
onLoad: T,
|
||||
onError: n[0] ||= (e) => u.value = !0
|
||||
}, null, 44, De)), [[z, !u.value]]), u.value ? (j(), W("div", Oe, [n[1] ||= A("i", {
|
||||
class: "pi pi-image",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1), A("span", null, G(e.alt), 1)])) : N("", !0)], 34));
|
||||
}
|
||||
}), Me = ["aria-label"], Ne = { class: "mjr-studio__header" }, Pe = { class: "mjr-studio__nav" }, Fe = { class: "mjr-studio__counter" }, Ie = { class: "mjr-studio__title" }, Le = ["title"], Re = { class: "mjr-studio__summary" }, ze = { class: "mjr-studio__actions" }, Be = ["title"], Ve = { class: "mjr-studio__body" }, He = { class: "mjr-studio__stage" }, Ue = {
|
||||
key: 2,
|
||||
class: "mjr-studio__unsupported"
|
||||
}, We = { class: "mjr-studio__review" }, Ge = { class: "mjr-studio__review-row" }, Ke = { class: "mjr-studio__label" }, qe = {
|
||||
class: "mjr-studio__tabs",
|
||||
role: "tablist"
|
||||
}, Je = { class: "mjr-studio__tab-body" }, Ye = ["aria-label"], Xe = ["src"], Ze = {
|
||||
key: 1,
|
||||
class: "pi pi-file",
|
||||
"aria-hidden": "true"
|
||||
}, Qe = {
|
||||
key: 2,
|
||||
class: "pi pi-play mjr-studio__thumb-badge",
|
||||
"aria-hidden": "true"
|
||||
}, $e = /* @__PURE__ */ M({
|
||||
__name: "StudioViewer",
|
||||
emits: ["open-classic"],
|
||||
setup(n, { emit: i }) {
|
||||
let u = i, f = H(null), p = H(null), m = H(null), h = H(!0), g = H("generation"), _ = H(!1), v = H(1), y = re(null), C = re(null), w = null, T = P(() => Z.assets[Z.index] ?? null), D = P(() => y.value ?? T.value), M = P(() => J(T.value)), F = P(() => T.value ? s(T.value) : ""), L = P(() => Z.assets.length), z = P(() => ue(D.value)), V = P(() => le(D.value, C.value)), ie = P(() => Number(C.value?.frame_count) || null), me = P(() => Number(D.value?.rating) || 0), he = P(() => Array.isArray(D.value?.tags) ? D.value.tags : []), ge = P(() => {
|
||||
let { start: e, end: t } = de(L.value, Z.index);
|
||||
return Z.assets.slice(e, t).map((t, n) => ({
|
||||
asset: t,
|
||||
index: e + n
|
||||
}));
|
||||
}), _e = P(() => L.value >= 2);
|
||||
async function ve(e) {
|
||||
if (w?.abort(), y.value = null, C.value = null, !e) return;
|
||||
let t = typeof AbortController < "u" ? new AbortController() : null;
|
||||
w = t;
|
||||
try {
|
||||
let n = await pe(e, {
|
||||
getAssetMetadata: r,
|
||||
getFileMetadataScoped: o,
|
||||
signal: t?.signal
|
||||
});
|
||||
w === t && T.value === e && (y.value = n && typeof n == "object" ? n : e);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
let n = J(e);
|
||||
if ((n === "video" || n === "audio") && e?.id != null) try {
|
||||
let n = await a(e.id, t ? { signal: t.signal } : {});
|
||||
w === t && n?.ok && (C.value = n.data || null);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}
|
||||
K(T, (e) => void ve(e), { immediate: !0 }), K(() => Z.open, async (e) => {
|
||||
e ? (await te(), f.value?.focus?.(), window.addEventListener("keydown", X, !0)) : (window.removeEventListener("keydown", X, !0), m.value?.release?.(), w?.abort(), document.fullscreenElement && f.value?.contains(document.fullscreenElement) && document.exitFullscreen?.());
|
||||
}, { immediate: !0 }), oe(() => {
|
||||
window.removeEventListener("keydown", X, !0), w?.abort();
|
||||
});
|
||||
function ye() {
|
||||
Te();
|
||||
}
|
||||
function Y(e = "") {
|
||||
let t = {
|
||||
assets: Z.assets.slice(),
|
||||
index: Z.index,
|
||||
mode: e
|
||||
};
|
||||
Te(), u("open-classic", t);
|
||||
}
|
||||
function be() {
|
||||
document.fullscreenElement ? document.exitFullscreen?.() : f.value?.requestFullscreen?.();
|
||||
}
|
||||
function xe() {
|
||||
if (!F.value) return;
|
||||
let e = document.createElement("a");
|
||||
e.href = F.value, e.download = String(T.value?.filename || ""), e.click();
|
||||
}
|
||||
async function Se(n) {
|
||||
let r = T.value;
|
||||
if (!r) return;
|
||||
let i = Math.max(0, Math.min(5, n)), a = await t(r, i);
|
||||
a?.ok ? (r.rating = i, y.value = y.value ? {
|
||||
...y.value,
|
||||
rating: i
|
||||
} : r, window.dispatchEvent(new CustomEvent(d, { detail: {
|
||||
assetId: r.id == null ? "" : String(r.id),
|
||||
rating: i
|
||||
} }))) : e(a?.error || c("toast.ratingUpdateFailed", "Rating update failed"), "error");
|
||||
}
|
||||
function Ce(e) {
|
||||
return !!e?.closest?.("input, textarea, select, [contenteditable='true']");
|
||||
}
|
||||
function X(e) {
|
||||
if (!Z.open) return;
|
||||
if (Ce(e.target)) {
|
||||
e.key === "Escape" && e.target?.blur?.();
|
||||
return;
|
||||
}
|
||||
let t = M.value === "video" || M.value === "audio", n = e.key, r = !0;
|
||||
n === "Escape" ? ye() : n === "ArrowLeft" && !e.shiftKey ? $(-1) : n === "ArrowRight" && !e.shiftKey ? $(1) : n === "ArrowLeft" && e.shiftKey && t ? m.value?.seekBy?.(-5) : n === "ArrowRight" && e.shiftKey && t ? m.value?.seekBy?.(5) : n === "Home" ? Q(0) : n === "End" ? Q(L.value - 1) : n === " " && t ? m.value?.togglePlay?.() : n === "," && t ? m.value?.stepFrame?.(-1) : n === "." && t ? m.value?.stepFrame?.(1) : (n === "i" || n === "I") && t ? m.value?.setLoopA?.() : (n === "o" || n === "O") && t ? m.value?.setLoopB?.() : (n === "u" || n === "U") && t ? m.value?.clearLoop?.() : (n === "m" || n === "M") && t ? m.value?.toggleMute?.() : n === "]" && t ? m.value?.cycleRate?.(1) : n === "[" && t ? m.value?.cycleRate?.(-1) : n === "Tab" ? h.value = !h.value : n === "f" || n === "F" ? be() : (n === "+" || n === "=") && M.value === "image" ? p.value?.zoomBy?.(1.25) : n === "-" && M.value === "image" ? p.value?.zoomBy?.(.8) : n === "0" && M.value === "image" && e.altKey ? p.value?.fit?.() : /^[0-5]$/.test(n) && !e.ctrlKey && !e.metaKey && !e.altKey ? Se(Number(n)) : (n === "c" || n === "C") && _e.value ? Y("ab") : r = !1, r && (e.preventDefault(), e.stopPropagation(), e.stopImmediatePropagation?.());
|
||||
}
|
||||
function we(e) {
|
||||
return l(e, 192) || "";
|
||||
}
|
||||
return (e, t) => {
|
||||
let n = ee("MButton");
|
||||
return j(), k(ae, { to: "body" }, [q(Z).open ? (j(), W("div", {
|
||||
key: 0,
|
||||
ref_key: "overlay",
|
||||
ref: f,
|
||||
class: B(["mjr-assets-manager mjr-studio", { "is-inspector-closed": !h.value }]),
|
||||
role: "dialog",
|
||||
"aria-modal": "true",
|
||||
"aria-label": q(c)("viewer.studio.title", "Viewer"),
|
||||
tabindex: "-1"
|
||||
}, [
|
||||
A("header", Ne, [
|
||||
A("div", Pe, [
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: q(c)("viewer.studio.prev", "Previous (←)"),
|
||||
disabled: L.value < 2,
|
||||
onClick: t[0] ||= (e) => q($)(-1)
|
||||
}, {
|
||||
default: R(() => [...t[14] ||= [A("i", {
|
||||
class: "pi pi-chevron-left",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title", "disabled"]),
|
||||
A("span", Fe, G(q(Z).index + 1) + " / " + G(L.value), 1),
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: q(c)("viewer.studio.next", "Next (→)"),
|
||||
disabled: L.value < 2,
|
||||
onClick: t[1] ||= (e) => q($)(1)
|
||||
}, {
|
||||
default: R(() => [...t[15] ||= [A("i", {
|
||||
class: "pi pi-chevron-right",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title", "disabled"])
|
||||
]),
|
||||
A("div", Ie, [A("span", {
|
||||
class: "mjr-studio__filename",
|
||||
title: D.value?.filepath || D.value?.filename
|
||||
}, G(D.value?.filename), 9, Le), A("span", Re, G(z.value), 1)]),
|
||||
A("div", ze, [
|
||||
M.value === "image" ? (j(), W(O, { key: 0 }, [
|
||||
A("span", {
|
||||
class: "mjr-studio-chip",
|
||||
title: q(c)("viewer.studio.zoomHint", "Wheel to zoom, double-click for fit / 100 %")
|
||||
}, G(Math.round(v.value * 100)) + "%", 9, Be),
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: q(c)("viewer.studio.fit", "Fit"),
|
||||
onClick: t[2] ||= (e) => p.value?.fit()
|
||||
}, {
|
||||
default: R(() => [...t[16] ||= [A("i", {
|
||||
class: "pi pi-arrows-alt",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"]),
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn is-text",
|
||||
title: q(c)("viewer.studio.actualSize", "Actual size"),
|
||||
onClick: t[3] ||= (e) => p.value?.actualSize()
|
||||
}, {
|
||||
default: R(() => [...t[17] ||= [E("1:1", -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"]),
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: B(["mjr-studio-btn", { "is-on": _.value }]),
|
||||
title: q(c)("viewer.studio.checker", "Transparency checkerboard"),
|
||||
onClick: t[4] ||= (e) => _.value = !_.value
|
||||
}, {
|
||||
default: R(() => [...t[18] ||= [A("i", {
|
||||
class: "pi pi-th-large",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["class", "title"])
|
||||
], 64)) : N("", !0),
|
||||
_e.value ? (j(), k(n, {
|
||||
key: 1,
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: q(c)("viewer.studio.compare", "Compare with next (C)"),
|
||||
onClick: t[5] ||= (e) => Y("ab")
|
||||
}, {
|
||||
default: R(() => [...t[19] ||= [A("i", {
|
||||
class: "pi pi-clone",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"])) : N("", !0),
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: q(c)("viewer.studio.download", "Download"),
|
||||
onClick: xe
|
||||
}, {
|
||||
default: R(() => [...t[20] ||= [A("i", {
|
||||
class: "pi pi-download",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"]),
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: q(c)("viewer.studio.classic", "Open in classic viewer (tools: scopes, loupe, grading)"),
|
||||
onClick: t[6] ||= (e) => Y("")
|
||||
}, {
|
||||
default: R(() => [...t[21] ||= [A("i", {
|
||||
class: "pi pi-sliders-h",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"]),
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: B(["mjr-studio-btn", { "is-on": h.value }]),
|
||||
title: q(c)("viewer.studio.inspector", "Generation info (Tab)"),
|
||||
onClick: t[7] ||= (e) => h.value = !h.value
|
||||
}, {
|
||||
default: R(() => [...t[22] ||= [A("i", {
|
||||
class: "pi pi-info-circle",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["class", "title"]),
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn",
|
||||
title: q(c)("viewer.studio.fullscreen", "Fullscreen (F)"),
|
||||
onClick: be
|
||||
}, {
|
||||
default: R(() => [...t[23] ||= [A("i", {
|
||||
class: "pi pi-window-maximize",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"]),
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn is-close",
|
||||
title: q(c)("viewer.studio.close", "Close (Esc)"),
|
||||
onClick: ye
|
||||
}, {
|
||||
default: R(() => [...t[24] ||= [A("i", {
|
||||
class: "pi pi-times",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1)]]),
|
||||
_: 1
|
||||
}, 8, ["title"])
|
||||
])
|
||||
]),
|
||||
A("main", Ve, [A("section", He, [M.value === "image" ? (j(), k(je, {
|
||||
ref_key: "imageStage",
|
||||
ref: p,
|
||||
key: `img:${F.value}`,
|
||||
src: F.value,
|
||||
alt: T.value?.filename,
|
||||
checker: _.value,
|
||||
onZoom: t[8] ||= (e) => v.value = e
|
||||
}, null, 8, [
|
||||
"src",
|
||||
"alt",
|
||||
"checker"
|
||||
])) : M.value === "video" || M.value === "audio" ? (j(), k(fe, {
|
||||
ref_key: "player",
|
||||
ref: m,
|
||||
key: `media:${F.value}`,
|
||||
src: F.value,
|
||||
kind: M.value,
|
||||
fps: V.value,
|
||||
"frame-count": ie.value,
|
||||
filename: T.value?.filename
|
||||
}, null, 8, [
|
||||
"src",
|
||||
"kind",
|
||||
"fps",
|
||||
"frame-count",
|
||||
"filename"
|
||||
])) : (j(), W("div", Ue, [
|
||||
t[25] ||= A("i", {
|
||||
class: "pi pi-file",
|
||||
"aria-hidden": "true"
|
||||
}, null, -1),
|
||||
A("span", null, G(q(c)("viewer.studio.unsupported", "No preview for this file type.")), 1),
|
||||
U(n, {
|
||||
type: "button",
|
||||
class: "mjr-studio-btn is-text",
|
||||
onClick: t[9] ||= (e) => Y("")
|
||||
}, {
|
||||
default: R(() => [E(G(q(c)("viewer.studio.classicShort", "Classic viewer")), 1)]),
|
||||
_: 1
|
||||
})
|
||||
]))]), h.value && D.value ? (j(), W("aside", {
|
||||
key: 0,
|
||||
class: "mjr-studio__inspector",
|
||||
onKeydown: t[13] ||= ne(() => {}, ["stop"])
|
||||
}, [
|
||||
A("div", We, [A("div", Ge, [A("span", Ke, G(q(c)("sidebar.rating", "Rating")), 1), (j(), k(se, {
|
||||
key: `r:${D.value.id}`,
|
||||
asset: D.value,
|
||||
"model-value": me.value,
|
||||
size: 40
|
||||
}, null, 8, ["asset", "model-value"]))]), (j(), k(ce, {
|
||||
key: `t:${D.value.id}`,
|
||||
asset: D.value,
|
||||
"model-value": he.value
|
||||
}, null, 8, ["asset", "model-value"]))]),
|
||||
A("nav", qe, [
|
||||
U(n, {
|
||||
type: "button",
|
||||
role: "tab",
|
||||
class: B({ "is-active": g.value === "generation" }),
|
||||
onClick: t[10] ||= (e) => g.value = "generation"
|
||||
}, {
|
||||
default: R(() => [E(G(q(c)("viewer.studio.tabGeneration", "Generation")), 1)]),
|
||||
_: 1
|
||||
}, 8, ["class"]),
|
||||
U(n, {
|
||||
type: "button",
|
||||
role: "tab",
|
||||
class: B({ "is-active": g.value === "workflow" }),
|
||||
onClick: t[11] ||= (e) => g.value = "workflow"
|
||||
}, {
|
||||
default: R(() => [E(G(q(c)("viewer.studio.tabWorkflow", "Workflow")), 1)]),
|
||||
_: 1
|
||||
}, 8, ["class"]),
|
||||
U(n, {
|
||||
type: "button",
|
||||
role: "tab",
|
||||
class: B({ "is-active": g.value === "file" }),
|
||||
onClick: t[12] ||= (e) => g.value = "file"
|
||||
}, {
|
||||
default: R(() => [E(G(q(c)("viewer.studio.tabFile", "File")), 1)]),
|
||||
_: 1
|
||||
}, 8, ["class"])
|
||||
]),
|
||||
A("div", Je, [g.value === "generation" ? (j(), k(x, {
|
||||
key: `g:${D.value.id}`,
|
||||
asset: D.value
|
||||
}, null, 8, ["asset"])) : g.value === "workflow" ? (j(), k(S, {
|
||||
key: `w:${D.value.id}`,
|
||||
asset: D.value
|
||||
}, null, 8, ["asset"])) : (j(), k(b, {
|
||||
key: `f:${D.value.id}`,
|
||||
asset: D.value
|
||||
}, null, 8, ["asset"]))])
|
||||
], 32)) : N("", !0)]),
|
||||
L.value > 1 ? (j(), W("footer", {
|
||||
key: 0,
|
||||
class: "mjr-studio__filmstrip",
|
||||
role: "listbox",
|
||||
"aria-label": q(c)("viewer.studio.filmstrip", "Filmstrip")
|
||||
}, [(j(!0), W(O, null, I(ge.value, (e) => (j(), k(n, {
|
||||
key: `${e.index}:${e.asset?.id ?? e.asset?.filename}`,
|
||||
type: "button",
|
||||
role: "option",
|
||||
class: B(["mjr-studio__thumb", { "is-current": e.index === q(Z).index }]),
|
||||
"aria-selected": e.index === q(Z).index,
|
||||
title: e.asset?.filename,
|
||||
onClick: (t) => q(Q)(e.index)
|
||||
}, {
|
||||
default: R(() => [we(e.asset) ? (j(), W("img", {
|
||||
key: 0,
|
||||
src: we(e.asset),
|
||||
alt: "",
|
||||
loading: "lazy",
|
||||
draggable: "false"
|
||||
}, null, 8, Xe)) : (j(), W("i", Ze)), q(J)(e.asset) === "video" ? (j(), W("i", Qe)) : N("", !0)]),
|
||||
_: 2
|
||||
}, 1032, [
|
||||
"class",
|
||||
"aria-selected",
|
||||
"title",
|
||||
"onClick"
|
||||
]))), 128))], 8, Ye)) : N("", !0)
|
||||
], 10, Me)) : N("", !0)]);
|
||||
};
|
||||
}
|
||||
}), et = /* @__PURE__ */ M({
|
||||
__name: "ViewerPortal",
|
||||
setup(e) {
|
||||
let t = null;
|
||||
function n(e) {
|
||||
let n = e?.detail || {}, i = Array.isArray(n?.assets) ? n.assets.filter(Boolean) : n?.asset ? [n.asset] : [];
|
||||
if (!i.length) return;
|
||||
let a = Math.max(0, Math.min(Number(n?.index) || 0, i.length - 1)), o = String(n?.mode || "").trim().toLowerCase();
|
||||
if (Ee(i, a, o) && we(i, a)) {
|
||||
try {
|
||||
t?.close?.();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
n.handled = !0;
|
||||
} else r(i, a, o, n);
|
||||
}
|
||||
function r(e, n, r, i = null) {
|
||||
Te();
|
||||
try {
|
||||
t ||= _(), t.open?.(e, n), (r === "ab" || r === "sidebyside") && t.setMode?.(r), i && (i.handled = !0);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}
|
||||
return D(() => {
|
||||
try {
|
||||
T();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
try {
|
||||
t = _();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
try {
|
||||
window.addEventListener(u.OPEN_VIEWER, n);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}), F(() => {
|
||||
try {
|
||||
window.removeEventListener(u.OPEN_VIEWER, n);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
try {
|
||||
w();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
try {
|
||||
t?.dispose?.();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
t = null;
|
||||
}), (e, t) => (j(), W(O, null, [
|
||||
U($e, { onOpenClassic: t[0] ||= (e) => r(e.assets, e.index, e.mode) }),
|
||||
U(he),
|
||||
U(me),
|
||||
U(X)
|
||||
], 64));
|
||||
}
|
||||
});
|
||||
export { et as default };
|
||||
+130
-2
@@ -2396,7 +2396,71 @@ var W = {
|
||||
"hotkey.details": "Toggle details (D)",
|
||||
"hotkey.delete": "Delete (Del)",
|
||||
"hotkey.viewer": "Open viewer (Enter)",
|
||||
"hotkey.escape": "Close (Esc)"
|
||||
"hotkey.escape": "Close (Esc)",
|
||||
"viewer.studio.abLoop": "A-B loop (I / O, U to clear)",
|
||||
"viewer.studio.actualSize": "Actual size",
|
||||
"viewer.studio.aspect": "Aspect ratio mask",
|
||||
"viewer.studio.channels": "Channels",
|
||||
"viewer.studio.checker": "Transparency checkerboard",
|
||||
"viewer.studio.classic": "Open in the classic viewer",
|
||||
"viewer.studio.classicShort": "Classic viewer",
|
||||
"viewer.studio.clearLoop": "Clear A-B loop",
|
||||
"viewer.studio.close": "Close (Esc)",
|
||||
"viewer.studio.compare": "Compare A/B (C)",
|
||||
"viewer.studio.compareTool": "Compare A/B (C)",
|
||||
"viewer.studio.copied": "Copied to clipboard",
|
||||
"viewer.studio.copy": "Copy image / frame to clipboard",
|
||||
"viewer.studio.copyFailed": "Could not copy this image",
|
||||
"viewer.studio.download": "Download",
|
||||
"viewer.studio.ev": "Exposure",
|
||||
"viewer.studio.exportFrame": "Save this frame as PNG",
|
||||
"viewer.studio.exposure": "Exposure / gamma",
|
||||
"viewer.studio.filmstrip": "Filmstrip",
|
||||
"viewer.studio.fit": "Fit",
|
||||
"viewer.studio.frame": "Frame",
|
||||
"viewer.studio.fullscreen": "Fullscreen (F)",
|
||||
"viewer.studio.gamma": "Gamma",
|
||||
"viewer.studio.grid": "Guides (G)",
|
||||
"viewer.studio.histogram": "Histogram",
|
||||
"viewer.studio.inspector": "Generation info (Tab)",
|
||||
"viewer.studio.loop": "Loop",
|
||||
"viewer.studio.loupe": "Loupe (L) - right-click for settings",
|
||||
"viewer.studio.loupeTitle": "Loupe",
|
||||
"viewer.studio.magnification": "Magnification",
|
||||
"viewer.studio.mediaError": "This file cannot be played by the browser.",
|
||||
"viewer.studio.mute": "Mute (M)",
|
||||
"viewer.studio.next": "Next (→)",
|
||||
"viewer.studio.nextB": "Next B",
|
||||
"viewer.studio.nextFrame": "Next frame (.)",
|
||||
"viewer.studio.opacity": "Opacity",
|
||||
"viewer.studio.pinB": "Use the current asset as B (B)",
|
||||
"viewer.studio.pinBShort": "Set B",
|
||||
"viewer.studio.play": "Play",
|
||||
"viewer.studio.playPause": "Play / pause (Space)",
|
||||
"viewer.studio.prev": "Previous (←)",
|
||||
"viewer.studio.prevB": "Previous B",
|
||||
"viewer.studio.prevFrame": "Previous frame (,)",
|
||||
"viewer.studio.probe": "Pixel probe (P)",
|
||||
"viewer.studio.reset": "Reset",
|
||||
"viewer.studio.resetTools": "Reset all tools (R)",
|
||||
"viewer.studio.scopes": "Scopes (S)",
|
||||
"viewer.studio.seek": "Seek",
|
||||
"viewer.studio.size": "Size",
|
||||
"viewer.studio.speed": "Speed ([ / ])",
|
||||
"viewer.studio.swap": "Swap A and B (X)",
|
||||
"viewer.studio.swapShort": "Swap",
|
||||
"viewer.studio.tabFile": "File",
|
||||
"viewer.studio.tabGeneration": "Generation",
|
||||
"viewer.studio.tabWorkflow": "Workflow",
|
||||
"viewer.studio.threshold": "Threshold",
|
||||
"viewer.studio.title": "Viewer",
|
||||
"viewer.studio.tools": "Review tools",
|
||||
"viewer.studio.unsupported": "No preview for this file type.",
|
||||
"viewer.studio.volume": "Volume",
|
||||
"viewer.studio.waveform": "Waveform",
|
||||
"viewer.studio.wipe": "Wipe",
|
||||
"viewer.studio.zebra": "Zebra highlights (Z)",
|
||||
"viewer.studio.zoomHint": "Wheel to zoom, double-click for fit / 100 %"
|
||||
},
|
||||
"fr-FR": {
|
||||
"tab.output": "Sortie",
|
||||
@@ -2892,7 +2956,71 @@ var W = {
|
||||
"msg.nightlyUpdateDetail": "Une build nightly plus recente est disponible. Télécharger : https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/releases/tag/nightly",
|
||||
"msg.newVersionTitle": "Mise à jour disponible",
|
||||
"msg.newVersionDetail": "La version {latest} est disponible. Version installée : {current}.",
|
||||
"tooltip.starGithub": "Ouvrir GitHub et mettre une etoile"
|
||||
"tooltip.starGithub": "Ouvrir GitHub et mettre une etoile",
|
||||
"viewer.studio.abLoop": "Boucle A-B (I / O, U pour effacer)",
|
||||
"viewer.studio.actualSize": "Taille réelle",
|
||||
"viewer.studio.aspect": "Masque de format",
|
||||
"viewer.studio.channels": "Canaux",
|
||||
"viewer.studio.checker": "Damier de transparence",
|
||||
"viewer.studio.classic": "Ouvrir dans le viewer classique",
|
||||
"viewer.studio.classicShort": "Viewer classique",
|
||||
"viewer.studio.clearLoop": "Effacer la boucle A-B",
|
||||
"viewer.studio.close": "Fermer (Échap)",
|
||||
"viewer.studio.compare": "Comparer A/B (C)",
|
||||
"viewer.studio.compareTool": "Comparer A/B (C)",
|
||||
"viewer.studio.copied": "Copié dans le presse-papiers",
|
||||
"viewer.studio.copy": "Copier l'image / la frame",
|
||||
"viewer.studio.copyFailed": "Impossible de copier cette image",
|
||||
"viewer.studio.download": "Télécharger",
|
||||
"viewer.studio.ev": "Exposition",
|
||||
"viewer.studio.exportFrame": "Enregistrer cette frame en PNG",
|
||||
"viewer.studio.exposure": "Exposition / gamma",
|
||||
"viewer.studio.filmstrip": "Pellicule",
|
||||
"viewer.studio.fit": "Ajuster",
|
||||
"viewer.studio.frame": "Frame",
|
||||
"viewer.studio.fullscreen": "Plein écran (F)",
|
||||
"viewer.studio.gamma": "Gamma",
|
||||
"viewer.studio.grid": "Guides (G)",
|
||||
"viewer.studio.histogram": "Histogramme",
|
||||
"viewer.studio.inspector": "Infos de génération (Tab)",
|
||||
"viewer.studio.loop": "Boucle",
|
||||
"viewer.studio.loupe": "Loupe (L) - clic droit pour les réglages",
|
||||
"viewer.studio.loupeTitle": "Loupe",
|
||||
"viewer.studio.magnification": "Grossissement",
|
||||
"viewer.studio.mediaError": "Ce fichier ne peut pas être lu par le navigateur.",
|
||||
"viewer.studio.mute": "Muet (M)",
|
||||
"viewer.studio.next": "Suivant (→)",
|
||||
"viewer.studio.nextB": "B suivant",
|
||||
"viewer.studio.nextFrame": "Frame suivante (.)",
|
||||
"viewer.studio.opacity": "Opacité",
|
||||
"viewer.studio.pinB": "Utiliser l'asset courant comme B (B)",
|
||||
"viewer.studio.pinBShort": "Définir B",
|
||||
"viewer.studio.play": "Lecture",
|
||||
"viewer.studio.playPause": "Lecture / pause (Espace)",
|
||||
"viewer.studio.prev": "Précédent (←)",
|
||||
"viewer.studio.prevB": "B précédent",
|
||||
"viewer.studio.prevFrame": "Frame précédente (,)",
|
||||
"viewer.studio.probe": "Pipette (P)",
|
||||
"viewer.studio.reset": "Réinitialiser",
|
||||
"viewer.studio.resetTools": "Réinitialiser les outils (R)",
|
||||
"viewer.studio.scopes": "Scopes (S)",
|
||||
"viewer.studio.seek": "Position",
|
||||
"viewer.studio.size": "Taille",
|
||||
"viewer.studio.speed": "Vitesse ([ / ])",
|
||||
"viewer.studio.swap": "Inverser A et B (X)",
|
||||
"viewer.studio.swapShort": "Inverser",
|
||||
"viewer.studio.tabFile": "Fichier",
|
||||
"viewer.studio.tabGeneration": "Génération",
|
||||
"viewer.studio.tabWorkflow": "Workflow",
|
||||
"viewer.studio.threshold": "Seuil",
|
||||
"viewer.studio.title": "Viewer",
|
||||
"viewer.studio.tools": "Outils de revue",
|
||||
"viewer.studio.unsupported": "Pas d'aperçu pour ce type de fichier.",
|
||||
"viewer.studio.volume": "Volume",
|
||||
"viewer.studio.waveform": "Waveform",
|
||||
"viewer.studio.wipe": "Volet",
|
||||
"viewer.studio.zebra": "Zébras (Z)",
|
||||
"viewer.studio.zoomHint": "Molette pour zoomer, double-clic pour ajuster / 100 %"
|
||||
}
|
||||
}, Mt = Object.freeze({
|
||||
"en-US": "English",
|
||||
Vendored
+3
-3
@@ -1,6 +1,6 @@
|
||||
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-CW2DfHq_.js";
|
||||
import { B as o, o as s, r as c } from "./events-cBMXVULQ.js";
|
||||
import { _ as t, d as n, o as r, s as i, t as a } from "./viewerRuntimeHosts-XPTU8ZhU.js";
|
||||
import { B as o, o as s, r as c } from "./events-CttACGx4.js";
|
||||
// ui/features/panel/panelRuntimeRefs.ts
|
||||
var l = null;
|
||||
function u(e) {
|
||||
@@ -96,7 +96,7 @@ var ne = /* @__PURE__ */ e({
|
||||
teardownFloatingViewerManager: () => Te
|
||||
}), _ = null, v = null;
|
||||
async function re() {
|
||||
return _ || (v ||= import("./FloatingViewer-DaVFZWfj.js").then((e) => (_ = e.FloatingViewer, _)), v);
|
||||
return _ || (v ||= import("./FloatingViewer-BWnwwSd7.js").then((e) => (_ = e.FloatingViewer, _)), v);
|
||||
}
|
||||
var y = null, b = null;
|
||||
async function ie() {
|
||||
@@ -1,8 +1,8 @@
|
||||
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
|
||||
import { m as t, o as n } from "./events-cBMXVULQ.js";
|
||||
import { v as r } from "./viewerOpenRequest-_DqD1rXA.js";
|
||||
import { i, n as a, r as o, t as s, u as c } from "./SidebarWorkflowSection-F3hoMYG1.js";
|
||||
import { D as l, E as u, H as d, N as f, O as p, T as m, ft as h, k as g, pt as _, ut as v } from "./mjr-primevue-3mpaVb1C.js";
|
||||
import { m as t, o as n } from "./events-CttACGx4.js";
|
||||
import { v as r } from "./viewerOpenRequest-CZXk8yvU.js";
|
||||
import { i, n as a, r as o, t as s, u as c } from "./SidebarWorkflowSection-DrSf3i6L.js";
|
||||
import { D as l, E as u, O as d, P as f, T as p, W as m, gt as h, ht as g, k as _, pt as v } from "./mjr-primevue-C07IsEdU.js";
|
||||
// ui/vue/components/viewer/ViewerMetadataBlock.vue?vue&type=script&setup=true&lang.ts
|
||||
var y = { style: {
|
||||
display: "flex",
|
||||
@@ -157,7 +157,7 @@ var y = { style: {
|
||||
function I() {
|
||||
typeof r.onRetry == "function" && r.onRetry();
|
||||
}
|
||||
let L = m(() => c(r.asset)), R = m(() => o(r.asset)), z = m(() => R.value.kind !== "empty"), B = m(() => P(r.asset)), V = m(() => n.WORKFLOW_MINIMAP_ENABLED !== !1 && N(r.asset)), H = m(() => L.value && typeof L.value == "object" && L.value.kind === "fetch_error"), U = m(() => F(r.asset?.metadata_raw)), W = m(() => !r.loading && !H.value && !B.value && !z.value && !V.value), G = m(() => {
|
||||
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.", {
|
||||
@@ -165,12 +165,12 @@ var y = { style: {
|
||||
code: n
|
||||
}) : t("viewer.metadataErrorRetry", "{message}\n\nClick to retry.", { message: e });
|
||||
});
|
||||
return (e, n) => (d(), g("div", y, [
|
||||
r.title ? (d(), g("div", b, _(r.title), 1)) : p("", !0),
|
||||
r.loading ? (d(), g("div", x, [u("div", S, _(v(t)("status.loading", "Loading")), 1), u("div", C, _(v(t)("viewer.loadingGenerationData", "Loading generation data...")), 1)])) : p("", !0),
|
||||
H.value ? (d(), g("div", {
|
||||
return (e, n) => (m(), _("div", y, [
|
||||
r.title ? (m(), _("div", b, h(r.title), 1)) : d("", !0),
|
||||
r.loading ? (m(), _("div", x, [u("div", S, h(v(t)("status.loading", "Loading")), 1), u("div", C, h(v(t)("viewer.loadingGenerationData", "Loading generation data...")), 1)])) : d("", !0),
|
||||
H.value ? (m(), _("div", {
|
||||
key: 2,
|
||||
style: h([{
|
||||
style: g([{
|
||||
padding: "10px 12px",
|
||||
"border-radius": "10px",
|
||||
border: "1px solid rgba(244,67,54,0.35)",
|
||||
@@ -179,21 +179,21 @@ var y = { style: {
|
||||
"white-space": "pre-wrap"
|
||||
}, { cursor: r.onRetry ? "pointer" : "default" }]),
|
||||
onClick: I
|
||||
}, [u("div", w, _(v(t)("viewer.errorLoadingMetadata", "Error Loading Metadata")), 1), u("div", T, _(G.value), 1)], 4)) : p("", !0),
|
||||
B.value ? (d(), l(i, {
|
||||
}, [u("div", w, h(v(t)("viewer.errorLoadingMetadata", "Error Loading Metadata")), 1), u("div", T, h(G.value), 1)], 4)) : d("", !0),
|
||||
B.value ? (m(), l(i, {
|
||||
key: 3,
|
||||
asset: r.asset
|
||||
}, null, 8, ["asset"])) : p("", !0),
|
||||
z.value ? (d(), l(a, {
|
||||
}, null, 8, ["asset"])) : d("", !0),
|
||||
z.value ? (m(), l(a, {
|
||||
key: 4,
|
||||
asset: r.asset
|
||||
}, null, 8, ["asset"])) : p("", !0),
|
||||
V.value ? (d(), l(s, {
|
||||
}, null, 8, ["asset"])) : d("", !0),
|
||||
V.value ? (m(), l(s, {
|
||||
key: 5,
|
||||
asset: r.asset
|
||||
}, null, 8, ["asset"])) : p("", !0),
|
||||
W.value ? (d(), g("div", E, _(v(t)("viewer.noGenerationDataFile", "No generation data found for this file.")), 1)) : p("", !0),
|
||||
U.value ? (d(), g("details", D, [u("summary", O, _(v(t)("msg.rawMetadata", "Raw metadata")), 1), u("pre", k, _(U.value), 1)])) : p("", !0)
|
||||
}, null, 8, ["asset"])) : d("", !0),
|
||||
W.value ? (m(), _("div", E, h(v(t)("viewer.noGenerationDataFile", "No generation data found for this file.")), 1)) : d("", !0),
|
||||
U.value ? (m(), _("details", D, [u("summary", O, h(v(t)("msg.rawMetadata", "Raw metadata")), 1), u("pre", k, h(U.value), 1)])) : d("", !0)
|
||||
]));
|
||||
}
|
||||
}), j = /* @__PURE__ */ e({
|
||||
+2845
-2790
File diff suppressed because one or more lines are too long
+45
-45
@@ -1,4 +1,4 @@
|
||||
import { $ as e, I as t, L as n, Q as r, R as i, T as a, Z as o, ct as s, et as c, it as l, nt as u, q as d, rt as f, st as p, tt as m } from "./mjr-primevue-3mpaVb1C.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 = o(!0), t = e.run(() => l({})), n = [], r = [], i = m({
|
||||
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 = [];
|
||||
},
|
||||
@@ -102,12 +102,12 @@ function N() {
|
||||
return i;
|
||||
}
|
||||
var P = () => {};
|
||||
function F(e, t, n, i = P) {
|
||||
function F(e, t, n, r = P) {
|
||||
e.add(t);
|
||||
let a = () => {
|
||||
e.delete(t) && i();
|
||||
let i = () => {
|
||||
e.delete(t) && r();
|
||||
};
|
||||
return !n && r() && u(a), a;
|
||||
return !n && p() && a(i), i;
|
||||
}
|
||||
function I(e, ...t) {
|
||||
e.forEach((e) => {
|
||||
@@ -115,14 +115,14 @@ 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], a = t[r];
|
||||
t[r] = b(a) && b(i) && Object.hasOwn(t, r) && !c(i) && !e(i) ? B(a, 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) {
|
||||
@@ -130,52 +130,52 @@ function H(e) {
|
||||
}
|
||||
var { assign: U } = Object;
|
||||
function W(e) {
|
||||
return !!(c(e) && e.effect);
|
||||
return !!(d(e) && e.effect);
|
||||
}
|
||||
function G(e, t, n, r) {
|
||||
let { state: i, actions: o, getters: c } = t, l = n.state.value[e], u;
|
||||
function d() {
|
||||
return l ||
|
||||
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(s(n.state.value[e]), o, Object.keys(c || {}).reduce((t, r) => (t[r] = m(a(() => {
|
||||
(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 c[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, r = {}, a, s, l) {
|
||||
let u, m = U({ actions: {} }, r), h = { deep: !0 }, g, v, y = /* @__PURE__ */ new Set(), b = /* @__PURE__ */ new Set(), x, S = a.state.value[t];
|
||||
!l && !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 */
|
||||
(a.state.value[t] = {});
|
||||
let C;
|
||||
function w(e) {
|
||||
let n;
|
||||
g = v = !1, typeof e == "function" ? (e(a.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(a.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, a.state.value[t]);
|
||||
let i = C = Symbol();
|
||||
e().then(() => {
|
||||
C === i && (g = !0);
|
||||
}), v = !0, I(y, r, a.state.value[t]);
|
||||
}
|
||||
let T = l ? function() {
|
||||
let T = c ? function() {
|
||||
let { state: e } = r, t = e ? e() : {};
|
||||
this.$patch((e) => {
|
||||
U(e, t);
|
||||
});
|
||||
} : P;
|
||||
function E() {
|
||||
u.stop(), y.clear(), b.clear(), a._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;
|
||||
@@ -212,7 +212,7 @@ function K(t, n, r = {}, a, s, l) {
|
||||
$reset: T,
|
||||
$subscribe(e, n = {}) {
|
||||
if (y.has(e)) return P;
|
||||
let r = F(y, e, n.detached, () => i()), i = u.run(() => d(() => a.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",
|
||||
@@ -222,14 +222,14 @@ function K(t, n, r = {}, a, s, l) {
|
||||
return r;
|
||||
},
|
||||
$dispose: E
|
||||
}, k = f(O);
|
||||
}, k = u(O);
|
||||
a._s.set(t, k);
|
||||
let A = (a._a && a._a.runWithContext || L)(() => a._e.run(() => (u = o()).run(() => n({ action: D }))));
|
||||
for (let n in A) {
|
||||
let r = A[n];
|
||||
c(r) && !W(r) || e(r) ? l || (S && H(r) && (c(r) ? r.value = S[n] : ((r instanceof Set || r instanceof Map) && r.clear(), B(r, S[n]))), a.state.value[t][n] = r) : typeof r == "function" && (A[n] = D(r, n), m.actions[n] = r);
|
||||
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(p(k), A), Object.defineProperty(k, "$state", {
|
||||
return U(k, A), U(m(k), A), Object.defineProperty(k, "$state", {
|
||||
get: () => a.state.value[t],
|
||||
set: (e) => {
|
||||
w((t) => {
|
||||
@@ -237,14 +237,14 @@ function K(t, n, r = {}, a, s, l) {
|
||||
});
|
||||
}
|
||||
}), a._p.forEach((e) => {
|
||||
let t = u.run(() => e({
|
||||
let t = f.run(() => e({
|
||||
store: k,
|
||||
app: a._a,
|
||||
pinia: a,
|
||||
options: m
|
||||
options: p
|
||||
}));
|
||||
U(k, t);
|
||||
}), S && l && r.hydrate && r.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";
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
|
||||
import { gt as t, m as n, o as r, r as i } from "./events-cBMXVULQ.js";
|
||||
import { gt as t, m as n, o as r, r as i } from "./events-CttACGx4.js";
|
||||
import { n as a } from "./state-D0Gu1AgG.js";
|
||||
// ui/features/viewer/model3dCore.ts
|
||||
function o(e, t) {
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { A as e, E as t, S as n, Z as r, b as i, j as a, m as o, z as s } from "./events-cBMXVULQ.js";
|
||||
import { A as e, E as t, S as n, Z as r, b as i, j as a, m as o, z as s } from "./events-CttACGx4.js";
|
||||
import { a as c, n as l } from "./graphTraversal-DTdQWkn5.js";
|
||||
import { S as u, Y as d, b as f, x as p, y as m } from "./SidebarWorkflowSection-F3hoMYG1.js";
|
||||
import { S as u, Y as d, b as f, x as p, y as m } from "./SidebarWorkflowSection-DrSf3i6L.js";
|
||||
// ui/features/viewer/floatingViewerProgress.ts
|
||||
var h = "progress-update", g = "__MJR_MFV_PROGRESS_SERVICE__";
|
||||
function ee() {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { i as e } from "./model3dRenderer-B-J9oQzQ.js";
|
||||
import { i as e } from "./model3dRenderer-CIUrxTBK.js";
|
||||
import { i as t, o as n } from "./geninfoParser-CgHp-y-n.js";
|
||||
// ui/features/viewer/sideBySide.ts
|
||||
function r(e) {
|
||||
+11
-11
@@ -1,10 +1,10 @@
|
||||
import { t as e } from "./rolldown-runtime-CGnMp29N.js";
|
||||
import { $t as t, H as n, P as r, _ as i, c as a, d as o, g as s, gt as c, n as l, o as u, r as d, s as f, vt as p, x as m, y as h } from "./viewerRuntimeHosts-CW2DfHq_.js";
|
||||
import { D as g, Tt as _, a as v, at as y, h as b, ht as x, i as S, j as C, k as w, m as T, n as E, o as D, r as O, t as k, ut as A } from "./events-cBMXVULQ.js";
|
||||
import { T as j, it as M, rt as N } from "./mjr-primevue-3mpaVb1C.js";
|
||||
import { n as ee, r as P } from "./mjr-vue-vendor-B23qUCEw.js";
|
||||
import { $t as t, H as n, P as r, _ as i, c as a, d as o, g as s, gt as c, n as l, o as u, r as d, s as f, vt as p, x as m, y as h } from "./viewerRuntimeHosts-XPTU8ZhU.js";
|
||||
import { D as g, Tt as _, a as v, at as y, h as b, ht as x, i as S, j as C, k as w, m as T, n as E, o as D, r as O, t as k, ut as A } from "./events-CttACGx4.js";
|
||||
import { T as j, ot as M, st as N } from "./mjr-primevue-C07IsEdU.js";
|
||||
import { n as ee, r as P } from "./mjr-vue-vendor-CEDOnn2t.js";
|
||||
import { n as F, r as te, t as ne } from "./state-D0Gu1AgG.js";
|
||||
import { a as re, c as ie, i as ae, o as oe, r as se, s as ce } from "./model3dRenderer-B-J9oQzQ.js";
|
||||
import { a as re, c as ie, i as ae, o as oe, r as se, s as ce } from "./model3dRenderer-CIUrxTBK.js";
|
||||
// ui/utils/events.ts
|
||||
function le(e, t, { target: n = null, warnPrefix: r = "[Majoor]" } = {}) {
|
||||
let i = n || (typeof window < "u" ? window : null);
|
||||
@@ -486,7 +486,7 @@ function J(e, t = {}) {
|
||||
}
|
||||
// ui/stores/useRuntimeStore.ts
|
||||
var ye = ee("mjr-runtime", () => {
|
||||
let e = M(null), t = M(null), n = M(!1), r = M(0), i = M(null), a = M(null), o = M(null), s = M(null), c = M(null), l = M([]), u = j(() => !!i.value), d = j(() => {
|
||||
let e = N(null), t = N(null), n = N(!1), r = N(0), i = N(null), a = N(null), o = N(null), s = N(null), c = N(null), l = N([]), u = j(() => !!i.value), d = j(() => {
|
||||
let e = s.value, t = c.value;
|
||||
return !t || t <= 0 || e == null ? 0 : Math.round(e / t * 100);
|
||||
});
|
||||
@@ -1241,7 +1241,7 @@ async function dt(e, t) {
|
||||
return !D.DELETE_CONFIRMATION || !!await L(e > 1 ? T("dialog.deleteSelectedFiles", "Delete {count} selected files?", { count: e }) : T("dialog.deleteSingleFile", "Delete \"{label}\"?", { label: String(t || T("label.thisFile", "this file")) }), T("dialog.confirmDeleteTitle", "Majoor: Confirm delete"));
|
||||
}
|
||||
// ui/features/collections/contextmenu/addToCollectionMenuState.ts
|
||||
var ft = N({
|
||||
var ft = M({
|
||||
open: !1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -2919,7 +2919,7 @@ function Xt() {
|
||||
title: ""
|
||||
};
|
||||
}
|
||||
var Q = N({
|
||||
var Q = M({
|
||||
portalOwnerId: "",
|
||||
mountedPortalIds: [],
|
||||
main: Xt(),
|
||||
@@ -9547,13 +9547,13 @@ var Vr = null, Hr = null, Ur = null, Wr = null, Gr = null, Kr = null;
|
||||
function qr() {
|
||||
Vr || import("./abCompare-BUrGm4-x.js").then((e) => {
|
||||
Vr = e;
|
||||
}), Hr || import("./sideBySide-BLNTzu0A.js").then((e) => {
|
||||
}), Hr || import("./sideBySide-FiIpKO3N.js").then((e) => {
|
||||
Hr = e;
|
||||
}), Ur || import("./model3dRenderer-B-J9oQzQ.js").then((e) => e.t).then((e) => {
|
||||
}), Ur || import("./model3dRenderer-CIUrxTBK.js").then((e) => e.t).then((e) => {
|
||||
Ur = e;
|
||||
}), Wr || import("./scopes-bvTRS68K.js").then((e) => {
|
||||
Wr = e;
|
||||
}), Gr || import("./genInfo-B3LeL9TU.js").then((e) => e.n).then((e) => {
|
||||
}), Gr || import("./genInfo-B9wv7JDq.js").then((e) => e.n).then((e) => {
|
||||
Gr = e;
|
||||
}), Kr || import("./frameExport-CnNO7oN6.js").then((e) => {
|
||||
Kr = e;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { Ct as e, Tt as t, _t as n, bt as r, it as i, k as a, m as o, o as s, rt as c, vt as l, wt as u, yt as d } from "./events-cBMXVULQ.js";
|
||||
import { Ct as e, Tt as t, _t as n, bt as r, it as i, k as a, m as o, o as s, rt as c, vt as l, wt as u, yt as d } from "./events-CttACGx4.js";
|
||||
// ui/app/settingsStore.ts
|
||||
var f = "mjrSettings", p = "mjrMinimapSettings", m = /* @__PURE__ */ new Set([
|
||||
"POST",
|
||||
Vendored
+764
-764
File diff suppressed because one or more lines are too long
@@ -15,10 +15,31 @@ Opening an asset (double-click, Enter, context menu) now shows the **Studio view
|
||||
|
||||
- **Header**: previous / next with `n / N`, file name, resolution · format · duration · size, and actions (zoom, fit, 1:1, transparency checkerboard, compare, download, classic viewer, 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. Media is released when you move on.
|
||||
- **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) |
|
||||
@@ -30,12 +51,16 @@ Opening an asset (double-click, Enter, context menu) now shows the **Studio view
|
||||
| `M` | Mute |
|
||||
| `0`–`5` | Set rating |
|
||||
| `+` / `-`, `Alt+0` | Zoom in / out, fit |
|
||||
| `C` | Compare with the next asset (classic A/B viewer) |
|
||||
| `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 |
|
||||
| `Esc` | Close the open tool panel, then the viewer |
|
||||
|
||||
The **classic viewer** below is still there: it is used for A/B and side-by-side compare, 3D models, and its analysis tools (scopes, loupe, grading) are one click away with the sliders button. To use it for everything, enable *Settings → Majoor Assets Manager → Viewer → Use the classic viewer*.
|
||||
The **classic viewer** below is still available (header sliders button, or *Settings → Majoor Assets Manager → Viewer → Use the classic viewer* to use it for everything).
|
||||
|
||||
### 1. Majoor Floating Viewer (MFV) — NEW! 🎉
|
||||
|
||||
|
||||
+130
@@ -1302,6 +1302,71 @@ const DICTIONARY: Record<string, Record<string, string>> = {
|
||||
"hotkey.delete": "Delete (Del)",
|
||||
"hotkey.viewer": "Open viewer (Enter)",
|
||||
"hotkey.escape": "Close (Esc)",
|
||||
// --- Studio viewer (main viewer and review tools) ---
|
||||
"viewer.studio.abLoop": "A-B loop (I / O, U to clear)",
|
||||
"viewer.studio.actualSize": "Actual size",
|
||||
"viewer.studio.aspect": "Aspect ratio mask",
|
||||
"viewer.studio.channels": "Channels",
|
||||
"viewer.studio.checker": "Transparency checkerboard",
|
||||
"viewer.studio.classic": "Open in the classic viewer",
|
||||
"viewer.studio.classicShort": "Classic viewer",
|
||||
"viewer.studio.clearLoop": "Clear A-B loop",
|
||||
"viewer.studio.close": "Close (Esc)",
|
||||
"viewer.studio.compare": "Compare A/B (C)",
|
||||
"viewer.studio.compareTool": "Compare A/B (C)",
|
||||
"viewer.studio.copied": "Copied to clipboard",
|
||||
"viewer.studio.copy": "Copy image / frame to clipboard",
|
||||
"viewer.studio.copyFailed": "Could not copy this image",
|
||||
"viewer.studio.download": "Download",
|
||||
"viewer.studio.ev": "Exposure",
|
||||
"viewer.studio.exportFrame": "Save this frame as PNG",
|
||||
"viewer.studio.exposure": "Exposure / gamma",
|
||||
"viewer.studio.filmstrip": "Filmstrip",
|
||||
"viewer.studio.fit": "Fit",
|
||||
"viewer.studio.frame": "Frame",
|
||||
"viewer.studio.fullscreen": "Fullscreen (F)",
|
||||
"viewer.studio.gamma": "Gamma",
|
||||
"viewer.studio.grid": "Guides (G)",
|
||||
"viewer.studio.histogram": "Histogram",
|
||||
"viewer.studio.inspector": "Generation info (Tab)",
|
||||
"viewer.studio.loop": "Loop",
|
||||
"viewer.studio.loupe": "Loupe (L) - right-click for settings",
|
||||
"viewer.studio.loupeTitle": "Loupe",
|
||||
"viewer.studio.magnification": "Magnification",
|
||||
"viewer.studio.mediaError": "This file cannot be played by the browser.",
|
||||
"viewer.studio.mute": "Mute (M)",
|
||||
"viewer.studio.next": "Next (→)",
|
||||
"viewer.studio.nextB": "Next B",
|
||||
"viewer.studio.nextFrame": "Next frame (.)",
|
||||
"viewer.studio.opacity": "Opacity",
|
||||
"viewer.studio.pinB": "Use the current asset as B (B)",
|
||||
"viewer.studio.pinBShort": "Set B",
|
||||
"viewer.studio.play": "Play",
|
||||
"viewer.studio.playPause": "Play / pause (Space)",
|
||||
"viewer.studio.prev": "Previous (←)",
|
||||
"viewer.studio.prevB": "Previous B",
|
||||
"viewer.studio.prevFrame": "Previous frame (,)",
|
||||
"viewer.studio.probe": "Pixel probe (P)",
|
||||
"viewer.studio.reset": "Reset",
|
||||
"viewer.studio.resetTools": "Reset all tools (R)",
|
||||
"viewer.studio.scopes": "Scopes (S)",
|
||||
"viewer.studio.seek": "Seek",
|
||||
"viewer.studio.size": "Size",
|
||||
"viewer.studio.speed": "Speed ([ / ])",
|
||||
"viewer.studio.swap": "Swap A and B (X)",
|
||||
"viewer.studio.swapShort": "Swap",
|
||||
"viewer.studio.tabFile": "File",
|
||||
"viewer.studio.tabGeneration": "Generation",
|
||||
"viewer.studio.tabWorkflow": "Workflow",
|
||||
"viewer.studio.threshold": "Threshold",
|
||||
"viewer.studio.title": "Viewer",
|
||||
"viewer.studio.tools": "Review tools",
|
||||
"viewer.studio.unsupported": "No preview for this file type.",
|
||||
"viewer.studio.volume": "Volume",
|
||||
"viewer.studio.waveform": "Waveform",
|
||||
"viewer.studio.wipe": "Wipe",
|
||||
"viewer.studio.zebra": "Zebra highlights (Z)",
|
||||
"viewer.studio.zoomHint": "Wheel to zoom, double-click for fit / 100 %",
|
||||
},
|
||||
|
||||
"fr-FR": {
|
||||
@@ -1871,6 +1936,71 @@ const DICTIONARY: Record<string, Record<string, string>> = {
|
||||
"msg.newVersionDetail":
|
||||
"La version {latest} est disponible. Version installée : {current}.",
|
||||
"tooltip.starGithub": "Ouvrir GitHub et mettre une etoile",
|
||||
// --- Studio viewer (viewer principal et outils de revue) ---
|
||||
"viewer.studio.abLoop": "Boucle A-B (I / O, U pour effacer)",
|
||||
"viewer.studio.actualSize": "Taille réelle",
|
||||
"viewer.studio.aspect": "Masque de format",
|
||||
"viewer.studio.channels": "Canaux",
|
||||
"viewer.studio.checker": "Damier de transparence",
|
||||
"viewer.studio.classic": "Ouvrir dans le viewer classique",
|
||||
"viewer.studio.classicShort": "Viewer classique",
|
||||
"viewer.studio.clearLoop": "Effacer la boucle A-B",
|
||||
"viewer.studio.close": "Fermer (Échap)",
|
||||
"viewer.studio.compare": "Comparer A/B (C)",
|
||||
"viewer.studio.compareTool": "Comparer A/B (C)",
|
||||
"viewer.studio.copied": "Copié dans le presse-papiers",
|
||||
"viewer.studio.copy": "Copier l'image / la frame",
|
||||
"viewer.studio.copyFailed": "Impossible de copier cette image",
|
||||
"viewer.studio.download": "Télécharger",
|
||||
"viewer.studio.ev": "Exposition",
|
||||
"viewer.studio.exportFrame": "Enregistrer cette frame en PNG",
|
||||
"viewer.studio.exposure": "Exposition / gamma",
|
||||
"viewer.studio.filmstrip": "Pellicule",
|
||||
"viewer.studio.fit": "Ajuster",
|
||||
"viewer.studio.frame": "Frame",
|
||||
"viewer.studio.fullscreen": "Plein écran (F)",
|
||||
"viewer.studio.gamma": "Gamma",
|
||||
"viewer.studio.grid": "Guides (G)",
|
||||
"viewer.studio.histogram": "Histogramme",
|
||||
"viewer.studio.inspector": "Infos de génération (Tab)",
|
||||
"viewer.studio.loop": "Boucle",
|
||||
"viewer.studio.loupe": "Loupe (L) - clic droit pour les réglages",
|
||||
"viewer.studio.loupeTitle": "Loupe",
|
||||
"viewer.studio.magnification": "Grossissement",
|
||||
"viewer.studio.mediaError": "Ce fichier ne peut pas être lu par le navigateur.",
|
||||
"viewer.studio.mute": "Muet (M)",
|
||||
"viewer.studio.next": "Suivant (→)",
|
||||
"viewer.studio.nextB": "B suivant",
|
||||
"viewer.studio.nextFrame": "Frame suivante (.)",
|
||||
"viewer.studio.opacity": "Opacité",
|
||||
"viewer.studio.pinB": "Utiliser l'asset courant comme B (B)",
|
||||
"viewer.studio.pinBShort": "Définir B",
|
||||
"viewer.studio.play": "Lecture",
|
||||
"viewer.studio.playPause": "Lecture / pause (Espace)",
|
||||
"viewer.studio.prev": "Précédent (←)",
|
||||
"viewer.studio.prevB": "B précédent",
|
||||
"viewer.studio.prevFrame": "Frame précédente (,)",
|
||||
"viewer.studio.probe": "Pipette (P)",
|
||||
"viewer.studio.reset": "Réinitialiser",
|
||||
"viewer.studio.resetTools": "Réinitialiser les outils (R)",
|
||||
"viewer.studio.scopes": "Scopes (S)",
|
||||
"viewer.studio.seek": "Position",
|
||||
"viewer.studio.size": "Taille",
|
||||
"viewer.studio.speed": "Vitesse ([ / ])",
|
||||
"viewer.studio.swap": "Inverser A et B (X)",
|
||||
"viewer.studio.swapShort": "Inverser",
|
||||
"viewer.studio.tabFile": "Fichier",
|
||||
"viewer.studio.tabGeneration": "Génération",
|
||||
"viewer.studio.tabWorkflow": "Workflow",
|
||||
"viewer.studio.threshold": "Seuil",
|
||||
"viewer.studio.title": "Viewer",
|
||||
"viewer.studio.tools": "Outils de revue",
|
||||
"viewer.studio.unsupported": "Pas d'aperçu pour ce type de fichier.",
|
||||
"viewer.studio.volume": "Volume",
|
||||
"viewer.studio.waveform": "Waveform",
|
||||
"viewer.studio.wipe": "Volet",
|
||||
"viewer.studio.zebra": "Zébras (Z)",
|
||||
"viewer.studio.zoomHint": "Molette pour zoomer, double-clic pour ajuster / 100 %",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Pure review-tool maths for the Studio viewer (no DOM): colour filters,
|
||||
* guides, aspect masks, scopes, zebra and pixel probing.
|
||||
*
|
||||
* Colour effects are expressed as SVG filter primitives so the GPU applies
|
||||
* them to images *and* playing video alike.
|
||||
*/
|
||||
|
||||
export type StudioChannel = "rgb" | "r" | "g" | "b" | "a" | "l";
|
||||
export type StudioGrid = "off" | "thirds" | "center" | "safe" | "golden";
|
||||
export type StudioAspect = "off" | "16:9" | "1:1" | "4:3" | "2.39" | "9:16";
|
||||
export type StudioScopes = "off" | "hist" | "wave" | "both";
|
||||
export type StudioCompare =
|
||||
"off" | "wipe" | "wipeV" | "side" | "difference" | "add" | "subtract" | "multiply" | "screen";
|
||||
|
||||
export interface StudioTools {
|
||||
channel: StudioChannel;
|
||||
exposure: number; // EV, -5..+5
|
||||
gamma: number; // 0.1..3
|
||||
zebra: boolean;
|
||||
zebraThreshold: number; // 0..1 luma
|
||||
scopes: StudioScopes;
|
||||
grid: StudioGrid;
|
||||
aspect: StudioAspect;
|
||||
maskOpacity: number; // 0..1
|
||||
probe: boolean;
|
||||
loupe: boolean;
|
||||
loupeSize: number;
|
||||
loupeZoom: number;
|
||||
compare: StudioCompare;
|
||||
wipe: number; // 0..100
|
||||
}
|
||||
|
||||
export function defaultStudioTools(): StudioTools {
|
||||
return {
|
||||
channel: "rgb",
|
||||
exposure: 0,
|
||||
gamma: 1,
|
||||
zebra: false,
|
||||
zebraThreshold: 0.95,
|
||||
scopes: "off",
|
||||
grid: "off",
|
||||
aspect: "off",
|
||||
maskOpacity: 0.65,
|
||||
probe: false,
|
||||
loupe: false,
|
||||
loupeSize: 140,
|
||||
loupeZoom: 8,
|
||||
compare: "off",
|
||||
wipe: 50,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rec.709 luma. */
|
||||
const LR = 0.2126;
|
||||
const LG = 0.7152;
|
||||
const LB = 0.0722;
|
||||
|
||||
/** feColorMatrix `values` (4x5) isolating a channel as greyscale, or null for RGB. */
|
||||
export function channelMatrix(channel: StudioChannel): string | null {
|
||||
const grey = (r: number, g: number, b: number, a = 0) =>
|
||||
[r, g, b, a, 0, r, g, b, a, 0, r, g, b, a, 0].join(" ");
|
||||
switch (channel) {
|
||||
case "r":
|
||||
return `${grey(1, 0, 0)} 0 0 0 1 0`;
|
||||
case "g":
|
||||
return `${grey(0, 1, 0)} 0 0 0 1 0`;
|
||||
case "b":
|
||||
return `${grey(0, 0, 1)} 0 0 0 1 0`;
|
||||
case "l":
|
||||
return `${grey(LR, LG, LB)} 0 0 0 1 0`;
|
||||
case "a":
|
||||
// Alpha shown as greyscale on an opaque canvas.
|
||||
return `${grey(0, 0, 0, 1)} 0 0 0 0 1`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function exposureSlope(ev: number): number {
|
||||
const safe = Number.isFinite(ev) ? Math.max(-10, Math.min(10, ev)) : 0;
|
||||
return Math.pow(2, safe);
|
||||
}
|
||||
|
||||
/** Display gamma: out = in^(1/gamma). */
|
||||
export function gammaExponent(gamma: number): number {
|
||||
const g = Number.isFinite(gamma) && gamma > 0 ? gamma : 1;
|
||||
return 1 / g;
|
||||
}
|
||||
|
||||
export function hasColorFx(tools: Pick<StudioTools, "channel" | "exposure" | "gamma">): boolean {
|
||||
return (
|
||||
tools.channel !== "rgb" ||
|
||||
Math.abs(tools.exposure) > 1e-3 ||
|
||||
Math.abs(tools.gamma - 1) > 1e-3
|
||||
);
|
||||
}
|
||||
|
||||
export function aspectRatioValue(aspect: StudioAspect): number | null {
|
||||
switch (aspect) {
|
||||
case "16:9":
|
||||
return 16 / 9;
|
||||
case "1:1":
|
||||
return 1;
|
||||
case "4:3":
|
||||
return 4 / 3;
|
||||
case "2.39":
|
||||
return 2.39;
|
||||
case "9:16":
|
||||
return 9 / 16;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Largest centred rect of `aspect` inside w×h (the visible area of the mask). */
|
||||
export function aspectRect(
|
||||
w: number,
|
||||
h: number,
|
||||
aspect: StudioAspect,
|
||||
): { x: number; y: number; w: number; h: number } | null {
|
||||
const ratio = aspectRatioValue(aspect);
|
||||
if (!ratio || w <= 0 || h <= 0) return null;
|
||||
let rw = w;
|
||||
let rh = w / ratio;
|
||||
if (rh > h) {
|
||||
rh = h;
|
||||
rw = h * ratio;
|
||||
}
|
||||
return { x: (w - rw) / 2, y: (h - rh) / 2, w: rw, h: rh };
|
||||
}
|
||||
|
||||
export interface GuideLine {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
export interface GuideRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Guide lines / rectangles in image pixel coordinates. */
|
||||
export function gridGuides(
|
||||
w: number,
|
||||
h: number,
|
||||
grid: StudioGrid,
|
||||
): { lines: GuideLine[]; rects: GuideRect[] } {
|
||||
const lines: GuideLine[] = [];
|
||||
const rects: GuideRect[] = [];
|
||||
if (w <= 0 || h <= 0 || grid === "off") return { lines, rects };
|
||||
const v = (f: number) => lines.push({ x1: w * f, y1: 0, x2: w * f, y2: h });
|
||||
const hz = (f: number) => lines.push({ x1: 0, y1: h * f, x2: w, y2: h * f });
|
||||
if (grid === "thirds") {
|
||||
v(1 / 3);
|
||||
v(2 / 3);
|
||||
hz(1 / 3);
|
||||
hz(2 / 3);
|
||||
} else if (grid === "golden") {
|
||||
const g = 0.381966;
|
||||
v(g);
|
||||
v(1 - g);
|
||||
hz(g);
|
||||
hz(1 - g);
|
||||
} else if (grid === "center") {
|
||||
v(0.5);
|
||||
hz(0.5);
|
||||
} else if (grid === "safe") {
|
||||
// Action safe (90 %) and title safe (80 %).
|
||||
for (const f of [0.9, 0.8]) {
|
||||
rects.push({ x: (w * (1 - f)) / 2, y: (h * (1 - f)) / 2, w: w * f, h: h * f });
|
||||
}
|
||||
v(0.5);
|
||||
hz(0.5);
|
||||
}
|
||||
return { lines, rects };
|
||||
}
|
||||
|
||||
export function luma(r: number, g: number, b: number): number {
|
||||
return LR * r + LG * g + LB * b;
|
||||
}
|
||||
|
||||
export interface Histogram {
|
||||
r: Uint32Array;
|
||||
g: Uint32Array;
|
||||
b: Uint32Array;
|
||||
l: Uint32Array;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/** 256-bin RGB + luma histogram of RGBA pixel data (alpha-weighted pixels skipped when transparent). */
|
||||
export function computeHistogram(data: Uint8ClampedArray | number[]): Histogram {
|
||||
const r = new Uint32Array(256);
|
||||
const g = new Uint32Array(256);
|
||||
const b = new Uint32Array(256);
|
||||
const l = new Uint32Array(256);
|
||||
for (let i = 0; i + 3 < data.length; i += 4) {
|
||||
if (data[i + 3] === 0) continue;
|
||||
r[data[i]] += 1;
|
||||
g[data[i + 1]] += 1;
|
||||
b[data[i + 2]] += 1;
|
||||
l[Math.round(luma(data[i], data[i + 1], data[i + 2]))] += 1;
|
||||
}
|
||||
let max = 1;
|
||||
for (let k = 1; k < 255; k++) max = Math.max(max, r[k], g[k], b[k], l[k]);
|
||||
return { r, g, b, l, max };
|
||||
}
|
||||
|
||||
/**
|
||||
* Luma waveform: `columns` × 256 density grid (column-major by image x),
|
||||
* values are counts of pixels at that luma in that column.
|
||||
*/
|
||||
export function computeWaveform(
|
||||
data: Uint8ClampedArray | number[],
|
||||
width: number,
|
||||
height: number,
|
||||
columns = 256,
|
||||
): { columns: number; bins: Uint32Array; max: number } {
|
||||
const cols = Math.max(1, Math.min(columns, width));
|
||||
const bins = new Uint32Array(cols * 256);
|
||||
let max = 1;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
if (data[i + 3] === 0) continue;
|
||||
const c = Math.min(cols - 1, Math.floor((x * cols) / width));
|
||||
const v = Math.round(luma(data[i], data[i + 1], data[i + 2]));
|
||||
const idx = c * 256 + v;
|
||||
bins[idx] += 1;
|
||||
if (bins[idx] > max) max = bins[idx];
|
||||
}
|
||||
}
|
||||
return { columns: cols, bins, max };
|
||||
}
|
||||
|
||||
/** Zebra stripes over pixels whose luma >= threshold (RGBA overlay, transparent elsewhere). */
|
||||
export function computeZebra(
|
||||
data: Uint8ClampedArray | number[],
|
||||
width: number,
|
||||
height: number,
|
||||
threshold: number,
|
||||
phase = 0,
|
||||
): Uint8ClampedArray<ArrayBuffer> {
|
||||
const out = new Uint8ClampedArray(new ArrayBuffer(width * height * 4));
|
||||
const limit = Math.max(0, Math.min(1, threshold)) * 255;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
if (luma(data[i], data[i + 1], data[i + 2]) < limit) continue;
|
||||
const stripe = Math.floor((x + y + phase) / 4) % 2 === 0;
|
||||
out[i] = stripe ? 255 : 20;
|
||||
out[i + 1] = stripe ? 40 : 20;
|
||||
out[i + 2] = stripe ? 40 : 20;
|
||||
out[i + 3] = 200;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Size that fits `w×h` within `max` on the long edge (for cheap sampling). */
|
||||
export function sampleSize(w: number, h: number, max = 256): { w: number; h: number } {
|
||||
if (w <= 0 || h <= 0) return { w: 0, h: 0 };
|
||||
const scale = Math.min(1, max / Math.max(w, h));
|
||||
return { w: Math.max(1, Math.round(w * scale)), h: Math.max(1, Math.round(h * scale)) };
|
||||
}
|
||||
|
||||
export function formatProbe(
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
a: number,
|
||||
): { hex: string; rgb: string; luma: string } {
|
||||
const hex =
|
||||
`#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("")}`.toUpperCase();
|
||||
return {
|
||||
hex,
|
||||
rgb: `${r}, ${g}, ${b}${a < 255 ? ` · α ${a}` : ""}`,
|
||||
luma: `${Math.round((luma(r, g, b) / 255) * 100)}%`,
|
||||
};
|
||||
}
|
||||
|
||||
/** CSS mix-blend-mode for blend compare modes, or null. */
|
||||
export function compareBlendMode(mode: StudioCompare): string | null {
|
||||
switch (mode) {
|
||||
case "difference":
|
||||
return "difference";
|
||||
case "add":
|
||||
return "plus-lighter";
|
||||
case "multiply":
|
||||
return "multiply";
|
||||
case "screen":
|
||||
return "screen";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const COMPARE_MODES: { value: StudioCompare; label: string; imageOnly?: boolean }[] = [
|
||||
{ value: "wipe", label: "Wipe ↔" },
|
||||
{ value: "wipeV", label: "Wipe ↕" },
|
||||
{ value: "side", label: "Side by side" },
|
||||
{ value: "difference", label: "Difference" },
|
||||
{ value: "add", label: "Add" },
|
||||
{ value: "subtract", label: "Subtract", imageOnly: true },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
{ value: "screen", label: "Screen" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Next index from `from` (stepping `delta`, wrapping) whose asset matches
|
||||
* `accept`; never returns `from` itself. Null when nothing matches.
|
||||
*/
|
||||
export function nextCompareIndex<T>(
|
||||
list: readonly T[],
|
||||
from: number,
|
||||
delta: number,
|
||||
accept: (item: T, index: number) => boolean = () => true,
|
||||
): number | null {
|
||||
const n = list.length;
|
||||
if (n < 2) return null;
|
||||
const step = delta < 0 ? -1 : 1;
|
||||
for (let k = 1; k < n; k++) {
|
||||
const i = (((from + step * k) % n) + n) % n;
|
||||
if (i !== from && accept(list[i], i)) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Pixel access for the Studio review tools (zebra, scopes, probe, loupe).
|
||||
*
|
||||
* Draws the *source* media (image or the current video frame) into small
|
||||
* reusable canvases. Sources are same-origin (/view, /mjr/am), so reading
|
||||
* pixels is allowed; failures (tainted canvas, frame not ready) return null.
|
||||
*/
|
||||
|
||||
import { sampleSize } from "./studioFx.js";
|
||||
|
||||
export type StudioSource = HTMLImageElement | HTMLVideoElement | HTMLCanvasElement;
|
||||
|
||||
let sampleCanvas: HTMLCanvasElement | null = null;
|
||||
let pixelCanvas: HTMLCanvasElement | null = null;
|
||||
|
||||
function canvas2d(existing: HTMLCanvasElement | null, w: number, h: number) {
|
||||
const c = existing || document.createElement("canvas");
|
||||
if (c.width !== w) c.width = w;
|
||||
if (c.height !== h) c.height = h;
|
||||
const ctx = c.getContext("2d", { willReadFrequently: true });
|
||||
return { c, ctx };
|
||||
}
|
||||
|
||||
export function sourceSize(source: StudioSource | null | undefined): { w: number; h: number } {
|
||||
if (!source) return { w: 0, h: 0 };
|
||||
if (source instanceof HTMLVideoElement)
|
||||
return { w: source.videoWidth || 0, h: source.videoHeight || 0 };
|
||||
if (source instanceof HTMLImageElement)
|
||||
return { w: source.naturalWidth || 0, h: source.naturalHeight || 0 };
|
||||
return { w: source.width || 0, h: source.height || 0 };
|
||||
}
|
||||
|
||||
export function isSourceReady(source: StudioSource | null | undefined): boolean {
|
||||
if (!source) return false;
|
||||
if (source instanceof HTMLVideoElement) return source.readyState >= 2 && source.videoWidth > 0;
|
||||
if (source instanceof HTMLImageElement) return source.complete && source.naturalWidth > 0;
|
||||
return source.width > 0;
|
||||
}
|
||||
|
||||
/** Downsampled RGBA pixels of the source (long edge <= max). */
|
||||
export function sampleImageData(
|
||||
source: StudioSource | null | undefined,
|
||||
max = 256,
|
||||
): { data: Uint8ClampedArray; width: number; height: number } | null {
|
||||
if (!isSourceReady(source)) return null;
|
||||
const { w, h } = sourceSize(source);
|
||||
const size = sampleSize(w, h, max);
|
||||
if (!size.w || !size.h) return null;
|
||||
try {
|
||||
const { c, ctx } = canvas2d(sampleCanvas, size.w, size.h);
|
||||
sampleCanvas = c;
|
||||
if (!ctx) return null;
|
||||
ctx.clearRect(0, 0, size.w, size.h);
|
||||
ctx.drawImage(source as CanvasImageSource, 0, 0, size.w, size.h);
|
||||
const img = ctx.getImageData(0, 0, size.w, size.h);
|
||||
return { data: img.data, width: size.w, height: size.h };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** RGBA at source pixel (x, y), or null. */
|
||||
export function pixelAt(
|
||||
source: StudioSource | null | undefined,
|
||||
x: number,
|
||||
y: number,
|
||||
): [number, number, number, number] | null {
|
||||
if (!isSourceReady(source)) return null;
|
||||
const { w, h } = sourceSize(source);
|
||||
const px = Math.floor(x);
|
||||
const py = Math.floor(y);
|
||||
if (px < 0 || py < 0 || px >= w || py >= h) return null;
|
||||
try {
|
||||
const { c, ctx } = canvas2d(pixelCanvas, 1, 1);
|
||||
pixelCanvas = c;
|
||||
if (!ctx) return null;
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.drawImage(source as CanvasImageSource, px, py, 1, 1, 0, 0, 1, 1);
|
||||
const d = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return [d[0], d[1], d[2], d[3]];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Nearest-neighbour magnification of the region around (x, y) into `target`. */
|
||||
export function drawLoupe(
|
||||
target: HTMLCanvasElement,
|
||||
source: StudioSource | null | undefined,
|
||||
x: number,
|
||||
y: number,
|
||||
zoom: number,
|
||||
): boolean {
|
||||
if (!isSourceReady(source)) return false;
|
||||
const ctx = target.getContext("2d");
|
||||
if (!ctx) return false;
|
||||
const span = target.width / Math.max(1, zoom);
|
||||
try {
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.fillRect(0, 0, target.width, target.height);
|
||||
ctx.drawImage(
|
||||
source as CanvasImageSource,
|
||||
Math.floor(x - span / 2),
|
||||
Math.floor(y - span / 2),
|
||||
Math.ceil(span),
|
||||
Math.ceil(span),
|
||||
0,
|
||||
0,
|
||||
target.width,
|
||||
target.height,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** PNG blob of the source at native resolution (copy / export current frame). */
|
||||
export async function sourceToPngBlob(
|
||||
source: StudioSource | null | undefined,
|
||||
): Promise<Blob | null> {
|
||||
if (!isSourceReady(source)) return null;
|
||||
const { w, h } = sourceSize(source);
|
||||
const c = document.createElement("canvas");
|
||||
c.width = w;
|
||||
c.height = h;
|
||||
try {
|
||||
c.getContext("2d")?.drawImage(source as CanvasImageSource, 0, 0);
|
||||
return await new Promise((resolve) => c.toBlob(resolve, "image/png"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
import { loadMajoorSettings } from "../../../app/settings/settingsCore.js";
|
||||
import { studioMediaKind } from "./studioMedia.js";
|
||||
|
||||
export const studioViewerState = reactive({
|
||||
open: false,
|
||||
@@ -17,17 +16,30 @@ export const studioViewerState = reactive({
|
||||
index: 0,
|
||||
/** Bumped on every open so the overlay can reset per-session UI. */
|
||||
session: 0,
|
||||
/** Compare mode requested by the opener ("wipe", "side") or "". */
|
||||
compare: "" as "" | "wipe" | "side",
|
||||
});
|
||||
|
||||
export function currentStudioAsset(): any {
|
||||
return studioViewerState.assets[studioViewerState.index] ?? null;
|
||||
}
|
||||
|
||||
export function openStudioViewer(assets: unknown[], index = 0): boolean {
|
||||
/** Map the classic viewer's open modes to a Studio compare mode. */
|
||||
export function studioCompareForMode(mode = ""): "" | "wipe" | "side" {
|
||||
const m = String(mode || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (m === "ab") return "wipe";
|
||||
if (m === "sidebyside") return "side";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function openStudioViewer(assets: unknown[], index = 0, mode = ""): boolean {
|
||||
const list = (Array.isArray(assets) ? assets : []).filter(Boolean);
|
||||
if (!list.length) return false;
|
||||
studioViewerState.assets = list;
|
||||
studioViewerState.index = Math.max(0, Math.min(Number(index) || 0, list.length - 1));
|
||||
studioViewerState.compare = list.length > 1 ? studioCompareForMode(mode) : "";
|
||||
studioViewerState.session += 1;
|
||||
studioViewerState.open = true;
|
||||
return true;
|
||||
@@ -50,14 +62,11 @@ export function stepStudioViewer(delta: number): void {
|
||||
/**
|
||||
* Whether an open request should use the Studio viewer.
|
||||
*
|
||||
* The classic viewer keeps the compare modes (A/B, side-by-side), 3D models,
|
||||
* and anyone who switched back to it in the settings.
|
||||
* The Studio viewer handles every media kind and the compare modes; only
|
||||
* users who switched back to the classic viewer in the settings (or a
|
||||
* non-media file) go to the classic runtime.
|
||||
*/
|
||||
export function shouldUseStudioViewer(assets: unknown[], index: number, mode = ""): boolean {
|
||||
const normalizedMode = String(mode || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalizedMode === "ab" || normalizedMode === "sidebyside") return false;
|
||||
export function shouldUseStudioViewer(assets: unknown[], index: number, _mode = ""): boolean {
|
||||
try {
|
||||
if (loadMajoorSettings()?.viewer?.classic === true) return false;
|
||||
} catch (e) {
|
||||
@@ -65,5 +74,5 @@ export function shouldUseStudioViewer(assets: unknown[], index: number, mode = "
|
||||
}
|
||||
const list = Array.isArray(assets) ? assets : [];
|
||||
const asset = list[Math.max(0, Math.min(Number(index) || 0, list.length - 1))];
|
||||
return !!asset && studioMediaKind(asset) !== "model3d";
|
||||
return !!asset;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
aspectRect,
|
||||
channelMatrix,
|
||||
compareBlendMode,
|
||||
computeHistogram,
|
||||
computeWaveform,
|
||||
computeZebra,
|
||||
defaultStudioTools,
|
||||
exposureSlope,
|
||||
formatProbe,
|
||||
gammaExponent,
|
||||
gridGuides,
|
||||
hasColorFx,
|
||||
nextCompareIndex,
|
||||
sampleSize,
|
||||
} from "../features/viewer/studio/studioFx.js";
|
||||
|
||||
describe("studio colour fx", () => {
|
||||
it("builds channel isolation matrices", () => {
|
||||
expect(channelMatrix("rgb")).toBeNull();
|
||||
const r = channelMatrix("r")!.split(" ").map(Number);
|
||||
expect(r).toHaveLength(20);
|
||||
// Every output colour row reads the red input only.
|
||||
expect([r[0], r[5], r[10]]).toEqual([1, 1, 1]);
|
||||
expect([r[1], r[2], r[6], r[12]]).toEqual([0, 0, 0, 0]);
|
||||
expect(r.slice(15)).toEqual([0, 0, 0, 1, 0]);
|
||||
const a = channelMatrix("a")!.split(" ").map(Number);
|
||||
expect([a[3], a[8], a[13]]).toEqual([1, 1, 1]);
|
||||
expect(a.slice(15)).toEqual([0, 0, 0, 0, 1]);
|
||||
const l = channelMatrix("l")!.split(" ").map(Number);
|
||||
expect(l[0] + l[1] + l[2]).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("maps exposure and gamma", () => {
|
||||
expect(exposureSlope(0)).toBe(1);
|
||||
expect(exposureSlope(1)).toBe(2);
|
||||
expect(exposureSlope(-2)).toBe(0.25);
|
||||
expect(exposureSlope(Number.NaN)).toBe(1);
|
||||
expect(gammaExponent(2)).toBe(0.5);
|
||||
expect(gammaExponent(0)).toBe(1);
|
||||
expect(hasColorFx(defaultStudioTools())).toBe(false);
|
||||
expect(hasColorFx({ ...defaultStudioTools(), exposure: 0.5 })).toBe(true);
|
||||
expect(hasColorFx({ ...defaultStudioTools(), channel: "g" })).toBe(true);
|
||||
});
|
||||
|
||||
it("maps blend compare modes", () => {
|
||||
expect(compareBlendMode("difference")).toBe("difference");
|
||||
expect(compareBlendMode("add")).toBe("plus-lighter");
|
||||
expect(compareBlendMode("wipe")).toBeNull();
|
||||
expect(compareBlendMode("subtract")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio guides", () => {
|
||||
it("fits aspect masks inside the frame", () => {
|
||||
expect(aspectRect(1000, 1000, "off")).toBeNull();
|
||||
expect(aspectRect(1920, 1920, "16:9")).toEqual({ x: 0, y: 420, w: 1920, h: 1080 });
|
||||
const tall = aspectRect(1600, 900, "9:16")!;
|
||||
expect(tall.h).toBe(900);
|
||||
expect(tall.w).toBeCloseTo(506.25, 5);
|
||||
expect(tall.x).toBeCloseTo((1600 - 506.25) / 2, 5);
|
||||
});
|
||||
|
||||
it("draws thirds, centre and safe areas", () => {
|
||||
expect(gridGuides(300, 300, "off").lines).toHaveLength(0);
|
||||
const thirds = gridGuides(300, 600, "thirds");
|
||||
expect(thirds.lines).toHaveLength(4);
|
||||
expect(thirds.lines[0]).toEqual({ x1: 100, y1: 0, x2: 100, y2: 600 });
|
||||
expect(thirds.lines[2]).toEqual({ x1: 0, y1: 200, x2: 300, y2: 200 });
|
||||
const safe = gridGuides(1000, 500, "safe");
|
||||
expect(safe.rects).toHaveLength(2);
|
||||
expect(safe.rects[0].x).toBeCloseTo(50, 6);
|
||||
expect(safe.rects[0].y).toBeCloseTo(25, 6);
|
||||
expect(safe.rects[0].w).toBeCloseTo(900, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("studio scopes and zebra", () => {
|
||||
// 2x1 image: pure white, pure black (both opaque) + 1 transparent pixel.
|
||||
const data = new Uint8ClampedArray([255, 255, 255, 255, 0, 0, 0, 255]);
|
||||
|
||||
it("computes histograms", () => {
|
||||
const h = computeHistogram(data);
|
||||
expect(h.r[255]).toBe(1);
|
||||
expect(h.r[0]).toBe(1);
|
||||
expect(h.l[255]).toBe(1);
|
||||
expect(computeHistogram(new Uint8ClampedArray([9, 9, 9, 0])).r[9]).toBe(0);
|
||||
});
|
||||
|
||||
it("computes a luma waveform per column", () => {
|
||||
const w = computeWaveform(data, 2, 1, 256);
|
||||
expect(w.columns).toBe(2);
|
||||
expect(w.bins[0 * 256 + 255]).toBe(1);
|
||||
expect(w.bins[1 * 256 + 0]).toBe(1);
|
||||
});
|
||||
|
||||
it("stripes only the highlights", () => {
|
||||
const z = computeZebra(data, 2, 1, 0.9);
|
||||
expect(z[3]).toBeGreaterThan(0);
|
||||
expect(z[7]).toBe(0);
|
||||
});
|
||||
|
||||
it("downsizes samples and formats probes", () => {
|
||||
expect(sampleSize(1024, 512, 256)).toEqual({ w: 256, h: 128 });
|
||||
expect(sampleSize(100, 50, 256)).toEqual({ w: 100, h: 50 });
|
||||
expect(formatProbe(255, 128, 0, 255)).toEqual({
|
||||
hex: "#FF8000",
|
||||
rgb: "255, 128, 0",
|
||||
luma: "57%",
|
||||
});
|
||||
expect(formatProbe(0, 0, 0, 10).rgb).toContain("α 10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compare B selection", () => {
|
||||
const list = ["img", "vid", "img", "img"];
|
||||
const isImg = (v: string) => v === "img";
|
||||
|
||||
it("steps to the next matching item and wraps", () => {
|
||||
expect(nextCompareIndex(list, 0, 1, isImg)).toBe(2);
|
||||
expect(nextCompareIndex(list, 3, 1, isImg)).toBe(0);
|
||||
expect(nextCompareIndex(list, 0, -1, isImg)).toBe(3);
|
||||
});
|
||||
|
||||
it("never returns the start and handles no match", () => {
|
||||
expect(nextCompareIndex(["img"], 0, 1)).toBeNull();
|
||||
expect(nextCompareIndex(list, 1, 1, (v) => v === "vid")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
timeForFrame,
|
||||
totalFrames,
|
||||
} from "../features/viewer/studio/studioMedia.js";
|
||||
import { shouldUseStudioViewer } from "../features/viewer/studio/studioViewerState.js";
|
||||
import {
|
||||
shouldUseStudioViewer,
|
||||
studioCompareForMode,
|
||||
} from "../features/viewer/studio/studioViewerState.js";
|
||||
|
||||
describe("studio media helpers", () => {
|
||||
it("formats time for short and long media", () => {
|
||||
@@ -85,11 +88,17 @@ describe("viewer routing", () => {
|
||||
expect(shouldUseStudioViewer([image], 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps compare modes, 3D and the classic setting on the classic viewer", () => {
|
||||
expect(shouldUseStudioViewer([image, image], 0, "ab")).toBe(false);
|
||||
expect(shouldUseStudioViewer([image, image], 0, "sidebyside")).toBe(false);
|
||||
expect(shouldUseStudioViewer([{ filename: "m.glb" }], 0)).toBe(false);
|
||||
it("handles compare modes and 3D; only the classic setting opts out", () => {
|
||||
expect(shouldUseStudioViewer([image, image], 0, "ab")).toBe(true);
|
||||
expect(shouldUseStudioViewer([image, image], 0, "sidebyside")).toBe(true);
|
||||
expect(shouldUseStudioViewer([{ filename: "m.glb" }], 0)).toBe(true);
|
||||
(loadMajoorSettings as any).mockReturnValueOnce({ viewer: { classic: true } });
|
||||
expect(shouldUseStudioViewer([image], 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("maps classic open modes to Studio compare modes", () => {
|
||||
expect(studioCompareForMode("ab")).toBe("wipe");
|
||||
expect(studioCompareForMode("SideBySide")).toBe("side");
|
||||
expect(studioCompareForMode("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,9 +53,14 @@ function mountViewer() {
|
||||
SidebarWorkflowSection: true,
|
||||
SidebarFileInfoSection: true,
|
||||
StudioImageStage: {
|
||||
props: ["src"],
|
||||
template: '<div class="stage-stub" :data-src="src" />',
|
||||
props: ["src", "compare", "compareSrc", "mediaFilter"],
|
||||
template:
|
||||
'<div class="stage-stub" :data-src="src" :data-compare="compare" :data-compare-src="compareSrc || \'\'" />',
|
||||
},
|
||||
StudioZebra: true,
|
||||
StudioScopes: true,
|
||||
StudioProbe: true,
|
||||
StudioModel3D: true,
|
||||
StudioPlayer: {
|
||||
props: ["src", "fps", "frameCount"],
|
||||
template: '<div class="player-stub" :data-fps="fps" />',
|
||||
@@ -129,15 +134,56 @@ describe("StudioViewer", () => {
|
||||
expect(studioViewerState.index).toBe(0);
|
||||
});
|
||||
|
||||
it("hands compare over to the classic viewer", async () => {
|
||||
it("toggles A/B compare in place and picks the next same-kind asset as B", async () => {
|
||||
const wrapper = mountViewer();
|
||||
openStudioViewer(ASSETS, 1);
|
||||
await flushPromises();
|
||||
|
||||
press("c");
|
||||
await nextTick();
|
||||
expect(studioViewerState.open).toBe(true);
|
||||
expect(wrapper.emitted("open-classic")).toBeUndefined();
|
||||
const stage = wrapper.find(".stage-stub");
|
||||
expect(stage.attributes("data-compare")).toBe("wipe");
|
||||
// B skips the video and wraps to the other image.
|
||||
expect(stage.attributes("data-compare-src")).toMatch(/\/asset\/1$/);
|
||||
|
||||
expect(studioViewerState.open).toBe(false);
|
||||
expect(wrapper.emitted("open-classic")?.[0]?.[0]).toMatchObject({ index: 1, mode: "ab" });
|
||||
press("x");
|
||||
await flushPromises();
|
||||
expect(studioViewerState.index).toBe(0);
|
||||
expect(wrapper.find(".stage-stub").attributes("data-compare-src")).toMatch(/\/asset\/2$/);
|
||||
|
||||
press("c");
|
||||
await nextTick();
|
||||
expect(wrapper.find(".stage-stub").attributes("data-compare")).toBe("off");
|
||||
});
|
||||
|
||||
it("opens with the compare mode requested by the caller", async () => {
|
||||
const wrapper = mountViewer();
|
||||
openStudioViewer(ASSETS, 0, "sidebyside");
|
||||
await flushPromises();
|
||||
expect(wrapper.find(".stage-stub").attributes("data-compare")).toBe("side");
|
||||
});
|
||||
|
||||
it("drives review tools from the rail shortcuts", async () => {
|
||||
const wrapper = mountViewer();
|
||||
openStudioViewer(ASSETS, 0);
|
||||
await flushPromises();
|
||||
expect(wrapper.find(".mjr-studio-rail").exists()).toBe(true);
|
||||
|
||||
press("z");
|
||||
press("g");
|
||||
press("p");
|
||||
await nextTick();
|
||||
const rail = wrapper.findComponent({ name: "StudioToolRail" });
|
||||
const tools = rail.props("tools") as any;
|
||||
expect(tools.zebra).toBe(true);
|
||||
expect(tools.grid).toBe("thirds");
|
||||
expect(tools.probe).toBe(true);
|
||||
|
||||
press("r");
|
||||
await nextTick();
|
||||
expect(tools.zebra).toBe(false);
|
||||
expect(tools.grid).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,8 +217,14 @@ describe("viewer Vue components", () => {
|
||||
);
|
||||
await flushUi(1);
|
||||
|
||||
expect(instance.open).toHaveBeenCalledWith(assets, 1);
|
||||
expect(instance.setMode).toHaveBeenCalledWith("ab");
|
||||
// Compare modes now open in the Studio viewer, preset to a wipe.
|
||||
const { studioViewerState, closeStudioViewer } =
|
||||
await import("../features/viewer/studio/studioViewerState.js");
|
||||
expect(studioViewerState.open).toBe(true);
|
||||
expect(studioViewerState.index).toBe(1);
|
||||
expect(studioViewerState.compare).toBe("wipe");
|
||||
expect(instance.open).not.toHaveBeenCalled();
|
||||
closeStudioViewer();
|
||||
|
||||
wrapper.unmount();
|
||||
|
||||
@@ -231,7 +237,7 @@ describe("viewer Vue components", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(instance.open).toHaveBeenCalledTimes(1);
|
||||
expect(instance.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders ViewerContextMenu.vue through Teleport and closes the main menu after an action", async () => {
|
||||
|
||||
+579
-3
@@ -10167,15 +10167,27 @@ body.mjr-mfv-popout-body {
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||
}
|
||||
.mjr-studio-stage__img {
|
||||
.mjr-studio-stage__box {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
max-width: none;
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
}
|
||||
.mjr-studio-stage__layers {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
isolation: isolate;
|
||||
}
|
||||
.mjr-studio-stage__img {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
will-change: transform;
|
||||
}
|
||||
.mjr-studio-stage__error {
|
||||
position: absolute;
|
||||
@@ -10490,3 +10502,567 @@ body.mjr-mfv-popout-body {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
/* ─── Studio review tools (HUD rail, compare, overlays) ─────────────────── */
|
||||
.mjr-studio-fx-defs {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mjr-studio-stage.is-probing,
|
||||
.mjr-studio-player.is-probing .mjr-studio-player__screen {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.mjr-studio-stage__side {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.mjr-studio-stage__divider {
|
||||
--mjr-k: var(--mjr-inv-scale, 1);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: calc(24px * var(--mjr-k));
|
||||
margin-left: calc(-12px * var(--mjr-k));
|
||||
cursor: ew-resize;
|
||||
z-index: 3;
|
||||
touch-action: none;
|
||||
}
|
||||
.mjr-studio-stage__divider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: calc(2px * var(--mjr-k));
|
||||
transform: translateX(-50%);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 calc(1px * var(--mjr-k)) rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.mjr-studio-stage__divider.is-vertical {
|
||||
top: auto;
|
||||
bottom: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: auto;
|
||||
height: calc(24px * var(--mjr-k));
|
||||
margin-left: 0;
|
||||
margin-top: calc(-12px * var(--mjr-k));
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.mjr-studio-stage__divider.is-vertical::before {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
width: auto;
|
||||
height: calc(2px * var(--mjr-k));
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.mjr-studio-stage__divider-grip {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 18px;
|
||||
height: 34px;
|
||||
transform: translate(-50%, -50%) scale(var(--mjr-k));
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.mjr-studio-stage__divider.is-vertical .mjr-studio-stage__divider-grip {
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
}
|
||||
.mjr-studio-stage__divider.is-active .mjr-studio-stage__divider-grip,
|
||||
.mjr-studio-stage__divider:hover .mjr-studio-stage__divider-grip {
|
||||
background: var(--mjr-accent, #4fa3ff);
|
||||
}
|
||||
.mjr-studio-stage__ab {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 12px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font: 600 11px/20px var(--mjr-font, system-ui, sans-serif);
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
.mjr-studio-stage__ab .is-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.mjr-studio-player__screen {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.mjr-studio-stage__side {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.mjr-studio-stage__divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 24px;
|
||||
margin-left: -12px;
|
||||
cursor: ew-resize;
|
||||
z-index: 3;
|
||||
touch-action: none;
|
||||
}
|
||||
.mjr-studio-stage__divider::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
margin-left: -1px;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.mjr-studio-stage__divider.is-vertical {
|
||||
top: auto;
|
||||
bottom: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: auto;
|
||||
height: 24px;
|
||||
margin-left: 0;
|
||||
margin-top: -12px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.mjr-studio-stage__divider.is-vertical::before {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
width: auto;
|
||||
height: 2px;
|
||||
margin-left: 0;
|
||||
margin-top: -1px;
|
||||
}
|
||||
.mjr-studio-stage__divider-grip {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 18px;
|
||||
height: 34px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.mjr-studio-stage__divider.is-vertical .mjr-studio-stage__divider-grip {
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
}
|
||||
.mjr-studio-stage__divider.is-active .mjr-studio-stage__divider-grip,
|
||||
.mjr-studio-stage__divider:hover .mjr-studio-stage__divider-grip {
|
||||
background: var(--mjr-accent, #4fa3ff);
|
||||
}
|
||||
.mjr-studio-stage__ab {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
z-index: 2;
|
||||
padding: 1px 7px;
|
||||
border-radius: 6px;
|
||||
font: 600 11px/18px var(--mjr-font, system-ui, sans-serif);
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
pointer-events: none;
|
||||
}
|
||||
.mjr-studio-stage__ab.is-a {
|
||||
left: 8px;
|
||||
}
|
||||
.mjr-studio-stage__ab.is-b {
|
||||
right: 8px;
|
||||
}
|
||||
.mjr-studio-stage__box .mjr-studio-stage__ab {
|
||||
/* Inside the zoomed box: keep labels readable at any zoom. */
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.mjr-studio-player__screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.mjr-studio-player__layers {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
isolation: isolate;
|
||||
}
|
||||
.mjr-studio-player__media.is-b {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.mjr-studio-player__screen.is-side .mjr-studio-player__layers {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.mjr-studio-player__screen.is-side .mjr-studio-player__media {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
width: auto;
|
||||
}
|
||||
.mjr-studio-player__box {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mjr-studio-player__box .mjr-studio-stage__divider {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mjr-studio-guides,
|
||||
.mjr-studio-zebra {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mjr-studio-zebra {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.mjr-studio-guides__line {
|
||||
stroke: rgba(255, 255, 255, 0.7);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.mjr-studio-guides__safe {
|
||||
stroke: rgba(255, 210, 80, 0.8);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 6 4;
|
||||
}
|
||||
.mjr-studio-guides__mask-edge {
|
||||
stroke: rgba(255, 255, 255, 0.5);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.mjr-studio-model3d {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
.mjr-studio-model3d__host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* HUD rail */
|
||||
.mjr-studio-rail {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
max-height: calc(100% - 24px);
|
||||
}
|
||||
.mjr-studio-rail__bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 5px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--mjr-surface-1, #1c1c20) 82%, transparent);
|
||||
border: 1px solid var(--mjr-studio-line, rgba(255, 255, 255, 0.1));
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.mjr-studio-rail__btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--mjr-text-muted, #b5b5bd);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
color 0.12s ease;
|
||||
}
|
||||
.mjr-studio-rail__btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--mjr-text, #fff);
|
||||
}
|
||||
.mjr-studio-rail__btn:focus-visible {
|
||||
outline: 2px solid var(--mjr-accent, #4fa3ff);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.mjr-studio-rail__btn.is-on {
|
||||
color: var(--mjr-accent, #4fa3ff);
|
||||
background: color-mix(in srgb, var(--mjr-accent, #4fa3ff) 16%, transparent);
|
||||
}
|
||||
.mjr-studio-rail__btn.is-open {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--mjr-text, #fff);
|
||||
}
|
||||
.mjr-studio-rail__tag {
|
||||
position: absolute;
|
||||
right: 1px;
|
||||
bottom: 1px;
|
||||
min-width: 12px;
|
||||
padding: 0 2px;
|
||||
border-radius: 4px;
|
||||
font: 700 8px/11px var(--mjr-font, system-ui, sans-serif);
|
||||
background: var(--mjr-accent, #4fa3ff);
|
||||
color: #fff;
|
||||
}
|
||||
.mjr-studio-rail__sep {
|
||||
height: 1px;
|
||||
margin: 3px 6px;
|
||||
background: var(--mjr-studio-line, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
.mjr-studio-rail__panel {
|
||||
position: absolute;
|
||||
left: 54px;
|
||||
width: 236px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--mjr-surface-1, #1c1c20) 92%, transparent);
|
||||
border: 1px solid var(--mjr-studio-line, rgba(255, 255, 255, 0.1));
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(12px);
|
||||
color: var(--mjr-text, #eee);
|
||||
font-size: 12px;
|
||||
}
|
||||
.mjr-studio-rail__title {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--mjr-text, #fff);
|
||||
}
|
||||
.mjr-studio-rail__b {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.mjr-studio-rail__b-label {
|
||||
font-weight: 700;
|
||||
color: var(--mjr-accent, #4fa3ff);
|
||||
}
|
||||
.mjr-studio-rail__b-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
color: var(--mjr-text-muted, #b5b5bd);
|
||||
}
|
||||
.mjr-studio-seg {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.mjr-studio-seg.is-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mjr-studio-seg button {
|
||||
flex: 1 1 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--mjr-text-muted, #b5b5bd);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mjr-studio-seg button:hover {
|
||||
color: var(--mjr-text, #fff);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.mjr-studio-seg button.is-on {
|
||||
background: var(--mjr-accent, #4fa3ff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
.mjr-studio-slider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.mjr-studio-slider__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--mjr-text-muted, #b5b5bd);
|
||||
font-size: 11px;
|
||||
}
|
||||
.mjr-studio-slider__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mjr-text, #fff);
|
||||
}
|
||||
.mjr-studio-slider__track {
|
||||
position: relative;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
outline: none;
|
||||
}
|
||||
.mjr-studio-slider__track::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 7px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.mjr-studio-slider__fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 7px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--mjr-accent, #4fa3ff);
|
||||
}
|
||||
.mjr-studio-slider__knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: -6px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.mjr-studio-slider__track:focus-visible .mjr-studio-slider__knob {
|
||||
outline: 2px solid var(--mjr-accent, #4fa3ff);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Scopes HUD */
|
||||
.mjr-studio-scopes {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mjr-studio-scopes__panel {
|
||||
position: relative;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(10, 10, 12, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.mjr-studio-scopes__panel canvas {
|
||||
display: block;
|
||||
width: 256px;
|
||||
height: 110px;
|
||||
}
|
||||
.mjr-studio-scopes__panel canvas.is-wave {
|
||||
height: 128px;
|
||||
}
|
||||
.mjr-studio-scopes__label {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 6px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Probe / loupe */
|
||||
.mjr-studio-probe {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10001;
|
||||
pointer-events: none;
|
||||
}
|
||||
.mjr-studio-probe__loupe {
|
||||
position: fixed;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.55);
|
||||
background: #111;
|
||||
}
|
||||
.mjr-studio-probe__loupe canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.mjr-studio-probe__cross {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.mjr-studio-probe__chip {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 10, 12, 0.85);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
font: 500 11px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mjr-studio-probe__chip .is-dim {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.mjr-studio-probe__swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Audio spectrum */
|
||||
.mjr-studio-player__audio-art.has-spectrum {
|
||||
width: min(720px, 86%);
|
||||
}
|
||||
.mjr-studio-player__spectrum {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
}
|
||||
.mjr-studio-player.is-compact .mjr-studio-player__spectrum {
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
@@ -91,11 +91,16 @@ function areTagsEqual(left: unknown, right: unknown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
asset: MjrAssetLike;
|
||||
modelValue?: string[] | string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
asset: MjrAssetLike;
|
||||
modelValue?: string[] | string;
|
||||
disabled?: boolean;
|
||||
/** Focus the input on mount (off where the editor is always visible). */
|
||||
autofocus?: boolean;
|
||||
}>(),
|
||||
{ modelValue: undefined, disabled: false, autofocus: true },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [tags: string[]];
|
||||
@@ -256,7 +261,7 @@ async function saveTags() {
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await nextTick();
|
||||
resolveDomElement(inputRef.value)?.focus();
|
||||
if (props.autofocus) resolveDomElement(inputRef.value)?.focus();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
|
||||
@@ -55,9 +55,9 @@ function _openFromEvent(event: Event) {
|
||||
const index = Math.max(0, Math.min(Number(detail?.index) || 0, assets.length - 1));
|
||||
const mode = String(detail?.mode || "").trim().toLowerCase();
|
||||
|
||||
// Studio viewer by default; compare modes, 3D and the "classic viewer"
|
||||
// setting go to the classic runtime below.
|
||||
if (shouldUseStudioViewer(assets, index, mode) && openStudioViewer(assets, index)) {
|
||||
// Studio viewer by default (compare modes and 3D included); the
|
||||
// "classic viewer" setting goes to the classic runtime below.
|
||||
if (shouldUseStudioViewer(assets, index, mode) && openStudioViewer(assets, index, mode)) {
|
||||
try {
|
||||
(_instance as any)?.close?.();
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StudioFxDefs.vue - hidden SVG filter applied with CSS `filter: url(#id)`.
|
||||
*
|
||||
* GPU-side, so it works on images and on playing video: optional A-B
|
||||
* subtraction (feImage of B), exposure, display gamma and channel isolation.
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
|
||||
import {
|
||||
channelMatrix,
|
||||
exposureSlope,
|
||||
gammaExponent,
|
||||
type StudioChannel,
|
||||
} from "../../../../features/viewer/studio/studioFx.js";
|
||||
|
||||
const props = defineProps<{
|
||||
filterId: string;
|
||||
channel: StudioChannel;
|
||||
exposure: number;
|
||||
gamma: number;
|
||||
/** When set, the filtered element shows A − B (B drawn via feImage). */
|
||||
subtractSrc?: string | null;
|
||||
}>();
|
||||
|
||||
const matrix = computed(() => channelMatrix(props.channel));
|
||||
const slope = computed(() => exposureSlope(props.exposure));
|
||||
const exponent = computed(() => gammaExponent(props.gamma));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg class="mjr-studio-fx-defs" width="0" height="0" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<filter
|
||||
:id="filterId"
|
||||
x="0"
|
||||
y="0"
|
||||
width="1"
|
||||
height="1"
|
||||
filterUnits="objectBoundingBox"
|
||||
primitiveUnits="objectBoundingBox"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<template v-if="subtractSrc">
|
||||
<!-- A - B computed as A + (1 - B) - 1 so alpha stays at 1. -->
|
||||
<feImage
|
||||
:href="subtractSrc"
|
||||
x="0"
|
||||
y="0"
|
||||
width="1"
|
||||
height="1"
|
||||
preserveAspectRatio="none"
|
||||
result="mjrB"
|
||||
/>
|
||||
<feComponentTransfer in="mjrB" result="mjrInvB">
|
||||
<feFuncR type="table" tableValues="1 0" />
|
||||
<feFuncG type="table" tableValues="1 0" />
|
||||
<feFuncB type="table" tableValues="1 0" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="mjrInvB"
|
||||
operator="arithmetic"
|
||||
k1="0"
|
||||
k2="1"
|
||||
k3="1"
|
||||
k4="-1"
|
||||
result="mjrBase"
|
||||
/>
|
||||
</template>
|
||||
<feComponentTransfer
|
||||
:in="subtractSrc ? 'mjrBase' : 'SourceGraphic'"
|
||||
result="mjrExposed"
|
||||
>
|
||||
<feFuncR type="linear" :slope="slope" />
|
||||
<feFuncG type="linear" :slope="slope" />
|
||||
<feFuncB type="linear" :slope="slope" />
|
||||
</feComponentTransfer>
|
||||
<feComponentTransfer in="mjrExposed" result="mjrGamma">
|
||||
<feFuncR type="gamma" amplitude="1" :exponent="exponent" offset="0" />
|
||||
<feFuncG type="gamma" amplitude="1" :exponent="exponent" offset="0" />
|
||||
<feFuncB type="gamma" amplitude="1" :exponent="exponent" offset="0" />
|
||||
</feComponentTransfer>
|
||||
<feColorMatrix v-if="matrix" in="mjrGamma" type="matrix" :values="matrix" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StudioGuides.vue - composition guides and aspect-ratio mask, drawn in the
|
||||
* media's pixel coordinates (the parent box scales with zoom).
|
||||
*/
|
||||
import { computed } from "vue";
|
||||
|
||||
import {
|
||||
aspectRect,
|
||||
gridGuides,
|
||||
type StudioAspect,
|
||||
type StudioGrid,
|
||||
} from "../../../../features/viewer/studio/studioFx.js";
|
||||
|
||||
const props = defineProps<{
|
||||
width: number;
|
||||
height: number;
|
||||
grid: StudioGrid;
|
||||
aspect: StudioAspect;
|
||||
maskOpacity: number;
|
||||
}>();
|
||||
|
||||
const guides = computed(() => gridGuides(props.width, props.height, props.grid));
|
||||
const mask = computed(() => aspectRect(props.width, props.height, props.aspect));
|
||||
const maskPath = computed(() => {
|
||||
const m = mask.value;
|
||||
if (!m) return "";
|
||||
const { width: w, height: h } = props;
|
||||
return `M0 0H${w}V${h}H0Z M${m.x} ${m.y}V${m.y + m.h}H${m.x + m.w}V${m.y}Z`;
|
||||
});
|
||||
const visible = computed(
|
||||
() => props.width > 0 && props.height > 0 && (props.grid !== "off" || !!mask.value),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
v-if="visible"
|
||||
class="mjr-studio-guides"
|
||||
:viewBox="`0 0 ${width} ${height}`"
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
v-if="maskPath"
|
||||
:d="maskPath"
|
||||
fill-rule="evenodd"
|
||||
:fill="`rgba(0,0,0,${maskOpacity})`"
|
||||
/>
|
||||
<rect
|
||||
v-if="mask"
|
||||
:x="mask.x"
|
||||
:y="mask.y"
|
||||
:width="mask.w"
|
||||
:height="mask.h"
|
||||
fill="none"
|
||||
class="mjr-studio-guides__mask-edge"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<line
|
||||
v-for="(l, i) in guides.lines"
|
||||
:key="`l${i}`"
|
||||
:x1="l.x1"
|
||||
:y1="l.y1"
|
||||
:x2="l.x2"
|
||||
:y2="l.y2"
|
||||
class="mjr-studio-guides__line"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<rect
|
||||
v-for="(r, i) in guides.rects"
|
||||
:key="`r${i}`"
|
||||
:x="r.x"
|
||||
:y="r.y"
|
||||
:width="r.w"
|
||||
:height="r.h"
|
||||
fill="none"
|
||||
class="mjr-studio-guides__safe"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -5,27 +5,68 @@
|
||||
* Wheel zooms around the cursor, drag pans, double-click toggles fit <-> 100 %.
|
||||
* Zoom is expressed relative to the image's natural size so "100 %" is a
|
||||
* pixel-accurate view of the generation.
|
||||
*
|
||||
* The image sits in a "box" at natural size that carries the zoom/pan
|
||||
* transform; review overlays (guides, zebra) go in the `overlay` slot and
|
||||
* follow it for free. A second image (B) can be compared: wipe, blend modes
|
||||
* or side by side - both share the same transform.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{ src: string; alt?: string; checker?: boolean }>();
|
||||
import {
|
||||
compareBlendMode,
|
||||
type StudioCompare,
|
||||
} from "../../../../features/viewer/studio/studioFx.js";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src: string;
|
||||
alt?: string;
|
||||
checker?: boolean;
|
||||
/** CSS filter applied to the picture (colour tools). */
|
||||
mediaFilter?: string;
|
||||
compareSrc?: string | null;
|
||||
compare?: StudioCompare;
|
||||
wipe?: number;
|
||||
}>(),
|
||||
{ alt: "", checker: false, mediaFilter: "", compareSrc: null, compare: "off", wipe: 50 },
|
||||
);
|
||||
const emit = defineEmits<{
|
||||
(e: "zoom", value: number): void;
|
||||
(e: "loaded", size: { width: number; height: number }): void;
|
||||
(e: "update:wipe", value: number): void;
|
||||
}>();
|
||||
|
||||
const stage = ref<HTMLElement | null>(null);
|
||||
const box = ref<HTMLElement | null>(null);
|
||||
const img = ref<HTMLImageElement | null>(null);
|
||||
const natural = ref({ w: 0, h: 0 });
|
||||
const scale = ref(1);
|
||||
const offset = ref({ x: 0, y: 0 });
|
||||
const fitMode = ref(true);
|
||||
const failed = ref(false);
|
||||
const dragging = ref(false);
|
||||
const wiping = ref(false);
|
||||
let drag: { x: number; y: number; ox: number; oy: number; id: number } | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const MIN_SCALE = 0.05;
|
||||
const MAX_SCALE = 32;
|
||||
const SIDE_GAP = 16;
|
||||
|
||||
const comparing = computed(() => !!props.compareSrc && props.compare !== "off");
|
||||
const isSide = computed(() => comparing.value && props.compare === "side");
|
||||
const isWipe = computed(
|
||||
() => comparing.value && (props.compare === "wipe" || props.compare === "wipeV"),
|
||||
);
|
||||
const blend = computed(() => (comparing.value ? compareBlendMode(props.compare) : null));
|
||||
/** Subtract is done by the colour filter (feImage of B), so B is not drawn. */
|
||||
const showB = computed(() => comparing.value && props.compare !== "subtract");
|
||||
|
||||
/** Content width used for fitting (side by side shows two images). */
|
||||
function contentWidth(): number {
|
||||
return isSide.value ? natural.value.w * 2 + SIDE_GAP : natural.value.w;
|
||||
}
|
||||
|
||||
function stageSize() {
|
||||
const rect = stage.value?.getBoundingClientRect();
|
||||
@@ -35,13 +76,16 @@ function stageSize() {
|
||||
function fitScale(): number {
|
||||
const { w, h } = stageSize();
|
||||
if (!natural.value.w || !natural.value.h || !w || !h) return 1;
|
||||
return Math.min(w / natural.value.w, h / natural.value.h);
|
||||
return Math.min(w / contentWidth(), h / natural.value.h);
|
||||
}
|
||||
|
||||
function applyFit() {
|
||||
fitMode.value = true;
|
||||
scale.value = fitScale();
|
||||
offset.value = { x: 0, y: 0 };
|
||||
offset.value = {
|
||||
x: isSide.value ? -((natural.value.w + SIDE_GAP) / 2) * scale.value : 0,
|
||||
y: 0,
|
||||
};
|
||||
emit("zoom", scale.value);
|
||||
}
|
||||
|
||||
@@ -106,20 +150,57 @@ function onPointerUp(event: PointerEvent) {
|
||||
dragging.value = false;
|
||||
}
|
||||
|
||||
function wipeFromEvent(event: PointerEvent) {
|
||||
const rect = box.value?.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
||||
const ratio =
|
||||
props.compare === "wipeV"
|
||||
? (event.clientY - rect.top) / rect.height
|
||||
: (event.clientX - rect.left) / rect.width;
|
||||
emit("update:wipe", Math.round(Math.max(0, Math.min(1, ratio)) * 1000) / 10);
|
||||
}
|
||||
function onWipeDown(event: PointerEvent) {
|
||||
wiping.value = true;
|
||||
(event.currentTarget as HTMLElement)?.setPointerCapture?.(event.pointerId);
|
||||
wipeFromEvent(event);
|
||||
}
|
||||
function onWipeMove(event: PointerEvent) {
|
||||
if (wiping.value) wipeFromEvent(event);
|
||||
}
|
||||
function onWipeUp() {
|
||||
wiping.value = false;
|
||||
}
|
||||
|
||||
function onLoad(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
natural.value = { w: img.naturalWidth, h: img.naturalHeight };
|
||||
const el = event.target as HTMLImageElement;
|
||||
natural.value = { w: el.naturalWidth, h: el.naturalHeight };
|
||||
failed.value = false;
|
||||
emit("loaded", { width: img.naturalWidth, height: img.naturalHeight });
|
||||
emit("loaded", { width: el.naturalWidth, height: el.naturalHeight });
|
||||
applyFit();
|
||||
}
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
const boxStyle = computed(() => ({
|
||||
width: `${natural.value.w}px`,
|
||||
height: `${natural.value.h}px`,
|
||||
transform: `translate(-50%, -50%) translate(${offset.value.x}px, ${offset.value.y}px) scale(${scale.value})`,
|
||||
imageRendering: scale.value >= 2 ? ("pixelated" as const) : ("auto" as const),
|
||||
// Lets in-box UI (wipe divider) keep a constant on-screen size.
|
||||
"--mjr-inv-scale": String(1 / (scale.value || 1)),
|
||||
}));
|
||||
const layersStyle = computed(() => (props.mediaFilter ? { filter: props.mediaFilter } : {}));
|
||||
const bStyle = computed(() => {
|
||||
if (blend.value) return { mixBlendMode: blend.value as any };
|
||||
if (isWipe.value) {
|
||||
const pct = Math.max(0, Math.min(100, props.wipe));
|
||||
return {
|
||||
clipPath: props.compare === "wipeV" ? `inset(${pct}% 0 0 0)` : `inset(0 0 0 ${pct}%)`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const dividerStyle = computed(() =>
|
||||
props.compare === "wipeV" ? { top: `${props.wipe}%` } : { left: `${props.wipe}%` },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
@@ -128,6 +209,9 @@ watch(
|
||||
failed.value = false;
|
||||
},
|
||||
);
|
||||
watch(isSide, () => {
|
||||
if (fitMode.value) applyFit();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
@@ -141,7 +225,15 @@ onMounted(() => {
|
||||
});
|
||||
onBeforeUnmount(() => resizeObserver?.disconnect());
|
||||
|
||||
defineExpose({ zoomBy, fit: applyFit, actualSize, toggleFitActual });
|
||||
defineExpose({
|
||||
zoomBy,
|
||||
fit: applyFit,
|
||||
actualSize,
|
||||
toggleFitActual,
|
||||
getSource: () => (img.value && !failed.value ? img.value : null),
|
||||
getBox: () => box.value,
|
||||
naturalSize: () => ({ ...natural.value }),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -156,17 +248,61 @@ defineExpose({ zoomBy, fit: applyFit, actualSize, toggleFitActual });
|
||||
@pointercancel="onPointerUp"
|
||||
@dblclick="toggleFitActual"
|
||||
>
|
||||
<img
|
||||
v-show="!failed"
|
||||
:key="src"
|
||||
class="mjr-studio-stage__img"
|
||||
:src="src"
|
||||
:alt="alt || ''"
|
||||
draggable="false"
|
||||
:style="imageStyle"
|
||||
@load="onLoad"
|
||||
@error="failed = true"
|
||||
/>
|
||||
<div v-show="!failed" class="mjr-studio-stage__box" :style="boxStyle">
|
||||
<div ref="box" class="mjr-studio-stage__layers" :style="layersStyle">
|
||||
<img
|
||||
ref="img"
|
||||
:key="src"
|
||||
class="mjr-studio-stage__img"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
draggable="false"
|
||||
@load="onLoad"
|
||||
@error="failed = true"
|
||||
/>
|
||||
<img
|
||||
v-if="showB && !isSide"
|
||||
:key="`b:${compareSrc}`"
|
||||
class="mjr-studio-stage__img is-b"
|
||||
:src="compareSrc || ''"
|
||||
alt=""
|
||||
draggable="false"
|
||||
:style="bStyle"
|
||||
/>
|
||||
</div>
|
||||
<slot name="overlay" :width="natural.w" :height="natural.h" />
|
||||
<template v-if="isWipe">
|
||||
<div
|
||||
class="mjr-studio-stage__divider"
|
||||
:class="{ 'is-vertical': compare === 'wipeV', 'is-active': wiping }"
|
||||
:style="dividerStyle"
|
||||
@pointerdown.stop="onWipeDown"
|
||||
@pointermove="onWipeMove"
|
||||
@pointerup="onWipeUp"
|
||||
@pointercancel="onWipeUp"
|
||||
@dblclick.stop="emit('update:wipe', 50)"
|
||||
>
|
||||
<span class="mjr-studio-stage__divider-grip" />
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="showB && isSide"
|
||||
class="mjr-studio-stage__side"
|
||||
:style="{ left: `calc(100% + ${SIDE_GAP}px)`, filter: mediaFilter || undefined }"
|
||||
>
|
||||
<img
|
||||
:key="`side:${compareSrc}`"
|
||||
class="mjr-studio-stage__img"
|
||||
:src="compareSrc || ''"
|
||||
alt=""
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="comparing" class="mjr-studio-stage__ab">
|
||||
<span>A</span><span class="is-sep">{{ compare === "wipeV" ? "/" : "|" }}</span
|
||||
><span>B</span>
|
||||
</div>
|
||||
<div v-if="failed" class="mjr-studio-stage__error">
|
||||
<i class="pi pi-image" aria-hidden="true" />
|
||||
<span>{{ alt }}</span>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StudioModel3D.vue - 3D model preview (GLB, OBJ, STL, splats...) inside the
|
||||
* Studio viewer, reusing the shared Three.js renderer of the classic viewer.
|
||||
*/
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
import { createModel3DMediaElement } from "../../../../features/viewer/model3dRenderer.js";
|
||||
|
||||
const props = defineProps<{ asset: any; src: string }>();
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
let element: HTMLElement | null = null;
|
||||
|
||||
function destroy() {
|
||||
try {
|
||||
element?.querySelectorAll?.("canvas").forEach((c: any) => {
|
||||
c?._mjrProc?.destroy?.();
|
||||
c._mjrProc = null;
|
||||
});
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
element?.remove();
|
||||
element = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!host.value || !props.src) return;
|
||||
try {
|
||||
element = createModel3DMediaElement(props.asset, props.src, {
|
||||
hostClassName: "mjr-model3d-host mjr-studio-model3d__host",
|
||||
canvasClassName: "mjr-model3d-render-canvas mjr-studio-model3d__canvas",
|
||||
hintText: "Rotate: left drag Pan: right drag Zoom: wheel or middle drag",
|
||||
disableViewerTransform: true,
|
||||
});
|
||||
if (element) host.value.appendChild(element);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(destroy);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="host" class="mjr-studio-model3d" @wheel.stop @pointerdown.stop @contextmenu.prevent />
|
||||
</template>
|
||||
@@ -7,9 +7,13 @@
|
||||
* Keyboard shortcuts are handled by the viewer and forwarded through the
|
||||
* exposed API so they work wherever the focus is inside the overlay.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { t } from "../../../../app/i18n.js";
|
||||
import {
|
||||
compareBlendMode,
|
||||
type StudioCompare,
|
||||
} from "../../../../features/viewer/studio/studioFx.js";
|
||||
import {
|
||||
PLAYBACK_RATES,
|
||||
formatMediaTime,
|
||||
@@ -31,13 +35,89 @@ const props = defineProps<{
|
||||
initialRate?: number;
|
||||
/** Tighter controls for small hosts (floating viewer). */
|
||||
compact?: boolean;
|
||||
/** CSS filter for the picture (Studio colour tools). */
|
||||
mediaFilter?: string;
|
||||
/** Second video kept in sync for A/B compare. */
|
||||
compareSrc?: string | null;
|
||||
compare?: StudioCompare;
|
||||
wipe?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ (e: "ended"): void }>();
|
||||
const emit = defineEmits<{ (e: "ended"): void; (e: "update:wipe", value: number): void }>();
|
||||
|
||||
const media = ref<HTMLVideoElement | HTMLAudioElement | null>(null);
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const track = ref<HTMLElement | null>(null);
|
||||
const screen = ref<HTMLElement | null>(null);
|
||||
const mediaB = ref<HTMLVideoElement | null>(null);
|
||||
/** Displayed picture rect inside the screen (object-fit: contain), in px. */
|
||||
const contentBox = ref({ x: 0, y: 0, w: 0, h: 0 });
|
||||
const wiping = ref(false);
|
||||
let screenObserver: ResizeObserver | null = null;
|
||||
|
||||
// ── Audio spectrum (audio files only) ──────────────────────────────────
|
||||
const spectrum = ref<HTMLCanvasElement | null>(null);
|
||||
const spectrumOn = ref(false);
|
||||
let audioCtx: AudioContext | null = null;
|
||||
let analyser: AnalyserNode | null = null;
|
||||
let spectrumRaf = 0;
|
||||
|
||||
/**
|
||||
* Route the <audio> through an analyser - only once the AudioContext is
|
||||
* confirmed running, since a suspended context would silence the element.
|
||||
*/
|
||||
async function ensureSpectrum() {
|
||||
if (props.kind !== "audio" || analyser || audioCtx) return;
|
||||
const m = el();
|
||||
const Ctor = (window as any).AudioContext || (window as any).webkitAudioContext;
|
||||
if (!m || !Ctor) return;
|
||||
try {
|
||||
audioCtx = new Ctor() as AudioContext;
|
||||
await audioCtx.resume?.();
|
||||
if (audioCtx.state !== "running") {
|
||||
void audioCtx.close?.();
|
||||
audioCtx = null;
|
||||
return;
|
||||
}
|
||||
const source = audioCtx.createMediaElementSource(m);
|
||||
analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyser.smoothingTimeConstant = 0.78;
|
||||
source.connect(analyser);
|
||||
analyser.connect(audioCtx.destination);
|
||||
spectrumOn.value = true;
|
||||
drawSpectrum();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}
|
||||
|
||||
function drawSpectrum() {
|
||||
cancelAnimationFrame(spectrumRaf);
|
||||
const c = spectrum.value;
|
||||
const ctx = c?.getContext("2d");
|
||||
if (!c || !ctx || !analyser) return;
|
||||
const w = (c.width = c.clientWidth || 480);
|
||||
const h = (c.height = c.clientHeight || 160);
|
||||
const bins = new Uint8Array(analyser.frequencyBinCount);
|
||||
analyser.getByteFrequencyData(bins);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const count = Math.min(64, bins.length);
|
||||
const gap = 3;
|
||||
const bw = Math.max(2, (w - gap * (count - 1)) / count);
|
||||
const accent = getComputedStyle(c).getPropertyValue("--mjr-accent").trim() || "#4fa3ff";
|
||||
ctx.fillStyle = accent;
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Log-ish spread so the low end does not dominate.
|
||||
const idx = Math.min(bins.length - 1, Math.floor(Math.pow(i / count, 1.6) * bins.length));
|
||||
const v = bins[idx] / 255;
|
||||
const bh = Math.max(2, v * h);
|
||||
ctx.globalAlpha = 0.35 + v * 0.65;
|
||||
ctx.fillRect(i * (bw + gap), h - bh, bw, bh);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
if (playing.value) spectrumRaf = requestAnimationFrame(drawSpectrum);
|
||||
}
|
||||
|
||||
const playing = ref(false);
|
||||
const current = ref(0);
|
||||
@@ -80,6 +160,90 @@ function el(): HTMLMediaElement | null {
|
||||
return media.value as HTMLMediaElement | null;
|
||||
}
|
||||
|
||||
const comparing = computed(
|
||||
() =>
|
||||
props.kind === "video" && !!props.compareSrc && !!props.compare && props.compare !== "off",
|
||||
);
|
||||
const isSide = computed(() => comparing.value && props.compare === "side");
|
||||
const isWipe = computed(
|
||||
() => comparing.value && (props.compare === "wipe" || props.compare === "wipeV"),
|
||||
);
|
||||
const wipePct = computed(() => Math.max(0, Math.min(100, Number(props.wipe ?? 50))));
|
||||
const bStyle = computed(() => {
|
||||
const blend = comparing.value ? compareBlendMode(props.compare || "off") : null;
|
||||
if (blend) return { mixBlendMode: blend as any };
|
||||
if (isWipe.value) {
|
||||
const b = contentBox.value;
|
||||
if (props.compare === "wipeV")
|
||||
return { clipPath: `inset(${b.y + (b.h * wipePct.value) / 100}px 0 0 0)` };
|
||||
return { clipPath: `inset(0 0 0 ${b.x + (b.w * wipePct.value) / 100}px)` };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const boxStyle = computed(() => ({
|
||||
left: `${contentBox.value.x}px`,
|
||||
top: `${contentBox.value.y}px`,
|
||||
width: `${contentBox.value.w}px`,
|
||||
height: `${contentBox.value.h}px`,
|
||||
}));
|
||||
|
||||
function measureContent() {
|
||||
const v = media.value;
|
||||
if (!(v instanceof HTMLVideoElement) || !screen.value) return;
|
||||
const cw = v.clientWidth;
|
||||
const ch = v.clientHeight;
|
||||
const vw = v.videoWidth;
|
||||
const vh = v.videoHeight;
|
||||
if (!cw || !ch || !vw || !vh) {
|
||||
contentBox.value = { x: v.offsetLeft, y: v.offsetTop, w: cw, h: ch };
|
||||
return;
|
||||
}
|
||||
const s = Math.min(cw / vw, ch / vh);
|
||||
const w = vw * s;
|
||||
const h = vh * s;
|
||||
contentBox.value = { x: v.offsetLeft + (cw - w) / 2, y: v.offsetTop + (ch - h) / 2, w, h };
|
||||
}
|
||||
|
||||
function syncB(force = false) {
|
||||
const a = el();
|
||||
const b = mediaB.value;
|
||||
if (!a || !b || !comparing.value) return;
|
||||
b.muted = true;
|
||||
if (b.playbackRate !== a.playbackRate) b.playbackRate = a.playbackRate;
|
||||
const target = Math.min(
|
||||
a.currentTime || 0,
|
||||
Number.isFinite(b.duration) ? b.duration : Infinity,
|
||||
);
|
||||
if (force || Math.abs((b.currentTime || 0) - target) > 0.08) {
|
||||
try {
|
||||
b.currentTime = target;
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
}
|
||||
if (a.paused && !b.paused) b.pause();
|
||||
else if (!a.paused && b.paused) void b.play()?.catch?.(() => {});
|
||||
}
|
||||
|
||||
function wipeFromEvent(event: PointerEvent) {
|
||||
const rect = screen.value?.getBoundingClientRect();
|
||||
const b = contentBox.value;
|
||||
if (!rect || b.w <= 0 || b.h <= 0) return;
|
||||
const ratio =
|
||||
props.compare === "wipeV"
|
||||
? (event.clientY - rect.top - b.y) / b.h
|
||||
: (event.clientX - rect.left - b.x) / b.w;
|
||||
emit("update:wipe", Math.round(Math.max(0, Math.min(1, ratio)) * 1000) / 10);
|
||||
}
|
||||
function onWipeDown(event: PointerEvent) {
|
||||
wiping.value = true;
|
||||
(event.currentTarget as HTMLElement)?.setPointerCapture?.(event.pointerId);
|
||||
wipeFromEvent(event);
|
||||
}
|
||||
function onWipeMove(event: PointerEvent) {
|
||||
if (wiping.value) wipeFromEvent(event);
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const m = el();
|
||||
if (m) {
|
||||
@@ -93,6 +257,7 @@ function tick() {
|
||||
m.currentTime = wrap;
|
||||
current.value = wrap;
|
||||
}
|
||||
syncB(wrap != null);
|
||||
}
|
||||
if (playing.value) rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
@@ -101,11 +266,17 @@ function onPlay() {
|
||||
playing.value = true;
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
syncB(true);
|
||||
if (props.kind === "audio") {
|
||||
if (analyser) drawSpectrum();
|
||||
else void ensureSpectrum();
|
||||
}
|
||||
scheduleHide();
|
||||
}
|
||||
function onPause() {
|
||||
playing.value = false;
|
||||
cancelAnimationFrame(rafId);
|
||||
syncB(true);
|
||||
showControls();
|
||||
}
|
||||
function onLoaded() {
|
||||
@@ -117,6 +288,7 @@ function onLoaded() {
|
||||
m.muted = muted.value;
|
||||
m.playbackRate = rate.value;
|
||||
}
|
||||
void nextTick(measureContent);
|
||||
}
|
||||
function onTimeUpdate() {
|
||||
const m = el();
|
||||
@@ -157,6 +329,7 @@ function seek(time: number) {
|
||||
const max = duration.value > 0 ? duration.value : time;
|
||||
m.currentTime = Math.max(0, Math.min(time, max));
|
||||
current.value = m.currentTime;
|
||||
syncB(true);
|
||||
}
|
||||
|
||||
function stepFrame(delta: number) {
|
||||
@@ -280,9 +453,7 @@ function scheduleHide() {
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
/** Release the decoder and stream (a detached, merely paused element keeps them). */
|
||||
function releaseMedia() {
|
||||
const m = el();
|
||||
function releaseElement(m: HTMLMediaElement | null) {
|
||||
if (!m) return;
|
||||
try {
|
||||
m.pause();
|
||||
@@ -293,6 +464,12 @@ function releaseMedia() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Release the decoder and stream (a detached, merely paused element keeps them). */
|
||||
function releaseMedia() {
|
||||
releaseElement(mediaB.value);
|
||||
releaseElement(el());
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
() => {
|
||||
@@ -321,9 +498,35 @@ function autoplay() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(autoplay);
|
||||
watch(
|
||||
[comparing, () => props.compareSrc, () => props.compare],
|
||||
() =>
|
||||
void nextTick(() => {
|
||||
measureContent();
|
||||
syncB(true);
|
||||
}),
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
autoplay();
|
||||
try {
|
||||
screenObserver = new ResizeObserver(() => measureContent());
|
||||
if (screen.value) screenObserver.observe(screen.value);
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
screenObserver?.disconnect();
|
||||
cancelAnimationFrame(spectrumRaf);
|
||||
try {
|
||||
void audioCtx?.close?.();
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
}
|
||||
audioCtx = null;
|
||||
analyser = null;
|
||||
cancelAnimationFrame(rafId);
|
||||
if (hideTimer) clearTimeout(hideTimer);
|
||||
releaseMedia();
|
||||
@@ -342,6 +545,12 @@ defineExpose({
|
||||
toggleFullscreen,
|
||||
exportFrame,
|
||||
release: releaseMedia,
|
||||
getSource: () => (media.value instanceof HTMLVideoElement ? media.value : null),
|
||||
getBox: () =>
|
||||
props.kind === "video"
|
||||
? (screen.value?.querySelector(".mjr-studio-player__box") as HTMLElement | null)
|
||||
: null,
|
||||
isPlaying: () => playing.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -356,25 +565,80 @@ defineExpose({
|
||||
}"
|
||||
@pointermove="showControls"
|
||||
>
|
||||
<video
|
||||
<div
|
||||
v-if="kind === 'video'"
|
||||
ref="media"
|
||||
class="mjr-studio-player__media"
|
||||
:src="src"
|
||||
playsinline
|
||||
preload="auto"
|
||||
ref="screen"
|
||||
class="mjr-studio-player__screen"
|
||||
:class="{ 'is-side': isSide }"
|
||||
@click="togglePlay"
|
||||
@dblclick="toggleFullscreen"
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@loadedmetadata="onLoaded"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@progress="onTimeUpdate"
|
||||
@ended="onEnded"
|
||||
@error="onError"
|
||||
/>
|
||||
<div v-else class="mjr-studio-player__audio-art" @click="togglePlay">
|
||||
<i class="pi pi-volume-up" aria-hidden="true" />
|
||||
>
|
||||
<div
|
||||
class="mjr-studio-player__layers"
|
||||
:style="mediaFilter ? { filter: mediaFilter } : undefined"
|
||||
>
|
||||
<video
|
||||
ref="media"
|
||||
class="mjr-studio-player__media"
|
||||
:src="src"
|
||||
playsinline
|
||||
preload="auto"
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@loadedmetadata="onLoaded"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@progress="onTimeUpdate"
|
||||
@ended="onEnded"
|
||||
@error="onError"
|
||||
/>
|
||||
<video
|
||||
v-if="comparing"
|
||||
ref="mediaB"
|
||||
:key="`b:${compareSrc}`"
|
||||
class="mjr-studio-player__media is-b"
|
||||
:src="compareSrc || ''"
|
||||
playsinline
|
||||
muted
|
||||
preload="auto"
|
||||
:style="bStyle"
|
||||
@loadedmetadata="syncB(true)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mjr-studio-player__box" :style="boxStyle">
|
||||
<slot name="overlay" :width="contentBox.w" :height="contentBox.h" />
|
||||
<div
|
||||
v-if="isWipe"
|
||||
class="mjr-studio-stage__divider"
|
||||
:class="{ 'is-vertical': compare === 'wipeV', 'is-active': wiping }"
|
||||
:style="compare === 'wipeV' ? { top: `${wipePct}%` } : { left: `${wipePct}%` }"
|
||||
@click.stop
|
||||
@dblclick.stop="emit('update:wipe', 50)"
|
||||
@pointerdown.stop="onWipeDown"
|
||||
@pointermove="onWipeMove"
|
||||
@pointerup="wiping = false"
|
||||
@pointercancel="wiping = false"
|
||||
>
|
||||
<span class="mjr-studio-stage__divider-grip" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="comparing" class="mjr-studio-stage__ab">
|
||||
<span>A</span><span class="is-sep">{{ compare === "wipeV" ? "/" : "|" }}</span
|
||||
><span>B</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mjr-studio-player__audio-art"
|
||||
:class="{ 'has-spectrum': spectrumOn }"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<canvas
|
||||
v-show="spectrumOn"
|
||||
ref="spectrum"
|
||||
class="mjr-studio-player__spectrum"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<i v-show="!spectrumOn" class="pi pi-volume-up" aria-hidden="true" />
|
||||
<span>{{ filename }}</span>
|
||||
<audio
|
||||
ref="media"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StudioProbe.vue - pixel probe (colour under the cursor) and loupe.
|
||||
* Maps the pointer onto the media box (which follows zoom/pan) to source pixels.
|
||||
*/
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef } from "vue";
|
||||
|
||||
import { formatProbe } from "../../../../features/viewer/studio/studioFx.js";
|
||||
import {
|
||||
drawLoupe,
|
||||
pixelAt,
|
||||
sourceSize,
|
||||
type StudioSource,
|
||||
} from "../../../../features/viewer/studio/studioSampler.js";
|
||||
|
||||
const props = defineProps<{
|
||||
getSource: () => StudioSource | null;
|
||||
getBox: () => HTMLElement | null;
|
||||
probe: boolean;
|
||||
loupe: boolean;
|
||||
loupeSize: number;
|
||||
loupeZoom: number;
|
||||
/** Same CSS filter as the media, so the loupe shows what the stage shows. */
|
||||
mediaFilter?: string;
|
||||
}>();
|
||||
|
||||
const loupeCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
const pos = ref<{ x: number; y: number } | null>(null);
|
||||
const info = shallowRef<{ x: number; y: number; hex: string; rgb: string; luma: string } | null>(
|
||||
null,
|
||||
);
|
||||
let raf = 0;
|
||||
let pending: PointerEvent | null = null;
|
||||
|
||||
function update() {
|
||||
raf = 0;
|
||||
const event = pending;
|
||||
pending = null;
|
||||
const box = props.getBox();
|
||||
const source = props.getSource();
|
||||
if (!event || !box || !source) return;
|
||||
const rect = box.getBoundingClientRect();
|
||||
const inside =
|
||||
event.clientX >= rect.left &&
|
||||
event.clientX <= rect.right &&
|
||||
event.clientY >= rect.top &&
|
||||
event.clientY <= rect.bottom;
|
||||
if (!inside || rect.width <= 0 || rect.height <= 0) {
|
||||
pos.value = null;
|
||||
info.value = null;
|
||||
return;
|
||||
}
|
||||
const { w, h } = sourceSize(source);
|
||||
const sx = ((event.clientX - rect.left) / rect.width) * w;
|
||||
const sy = ((event.clientY - rect.top) / rect.height) * h;
|
||||
pos.value = { x: event.clientX, y: event.clientY };
|
||||
if (props.loupe && loupeCanvas.value)
|
||||
drawLoupe(loupeCanvas.value, source, sx, sy, props.loupeZoom);
|
||||
if (props.probe) {
|
||||
const px = pixelAt(source, sx, sy);
|
||||
info.value = px
|
||||
? { x: Math.floor(sx), y: Math.floor(sy), ...formatProbe(px[0], px[1], px[2], px[3]) }
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function onMove(event: PointerEvent) {
|
||||
pending = event;
|
||||
if (!raf) raf = requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("pointermove", onMove, { passive: true }));
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="pos" class="mjr-studio-probe" aria-hidden="true">
|
||||
<div
|
||||
v-if="loupe"
|
||||
class="mjr-studio-probe__loupe"
|
||||
:style="{
|
||||
left: `${pos.x + 18}px`,
|
||||
top: `${pos.y + 18}px`,
|
||||
width: `${loupeSize}px`,
|
||||
height: `${loupeSize}px`,
|
||||
}"
|
||||
>
|
||||
<canvas
|
||||
ref="loupeCanvas"
|
||||
:width="loupeSize"
|
||||
:height="loupeSize"
|
||||
:style="mediaFilter ? { filter: mediaFilter } : undefined"
|
||||
/>
|
||||
<span class="mjr-studio-probe__cross" />
|
||||
</div>
|
||||
<div
|
||||
v-if="probe && info"
|
||||
class="mjr-studio-probe__chip"
|
||||
:style="{ left: `${pos.x + 18}px`, top: `${pos.y + (loupe ? loupeSize + 26 : 18)}px` }"
|
||||
>
|
||||
<span class="mjr-studio-probe__swatch" :style="{ background: info.hex }" />
|
||||
<span>{{ info.hex }}</span>
|
||||
<span class="is-dim">{{ info.rgb }}</span>
|
||||
<span class="is-dim">L {{ info.luma }} · {{ info.x }},{{ info.y }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
/** StudioScopes.vue - RGB/luma histogram and luma waveform HUD. */
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
|
||||
import { t } from "../../../../app/i18n.js";
|
||||
import {
|
||||
computeHistogram,
|
||||
computeWaveform,
|
||||
type StudioScopes,
|
||||
} from "../../../../features/viewer/studio/studioFx.js";
|
||||
import {
|
||||
sampleImageData,
|
||||
type StudioSource,
|
||||
} from "../../../../features/viewer/studio/studioSampler.js";
|
||||
|
||||
const props = defineProps<{
|
||||
getSource: () => StudioSource | null;
|
||||
mode: StudioScopes;
|
||||
tick: number;
|
||||
}>();
|
||||
const hist = ref<HTMLCanvasElement | null>(null);
|
||||
const wave = ref<HTMLCanvasElement | null>(null);
|
||||
|
||||
function drawHistogram(data: Uint8ClampedArray) {
|
||||
const c = hist.value;
|
||||
const ctx = c?.getContext("2d");
|
||||
if (!c || !ctx) return;
|
||||
const h = computeHistogram(data);
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
const plot = (bins: Uint32Array, color: string) => {
|
||||
ctx.fillStyle = color;
|
||||
for (let x = 0; x < 256; x++) {
|
||||
const v = Math.min(1, bins[x] / h.max) * c.height;
|
||||
ctx.fillRect((x / 256) * c.width, c.height - v, c.width / 256 + 0.5, v);
|
||||
}
|
||||
};
|
||||
plot(h.r, "rgba(255,70,70,0.75)");
|
||||
plot(h.g, "rgba(70,255,90,0.75)");
|
||||
plot(h.b, "rgba(80,140,255,0.75)");
|
||||
ctx.globalCompositeOperation = "source-over";
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.85)";
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x < 256; x++) {
|
||||
const y = c.height - Math.min(1, h.l[x] / h.max) * c.height;
|
||||
if (x === 0) ctx.moveTo(0, y);
|
||||
else ctx.lineTo((x / 255) * c.width, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawWaveform(data: Uint8ClampedArray, width: number, height: number) {
|
||||
const c = wave.value;
|
||||
const ctx = c?.getContext("2d");
|
||||
if (!c || !ctx) return;
|
||||
const w = computeWaveform(data, width, height, 256);
|
||||
const img = ctx.createImageData(w.columns, 256);
|
||||
for (let col = 0; col < w.columns; col++) {
|
||||
for (let v = 0; v < 256; v++) {
|
||||
const n = w.bins[col * 256 + v];
|
||||
if (!n) continue;
|
||||
const a = Math.min(255, 40 + (n / w.max) * 1200);
|
||||
const i = ((255 - v) * w.columns + col) * 4;
|
||||
img.data[i] = 120;
|
||||
img.data[i + 1] = 255;
|
||||
img.data[i + 2] = 150;
|
||||
img.data[i + 3] = a;
|
||||
}
|
||||
}
|
||||
c.width = w.columns;
|
||||
c.height = 256;
|
||||
ctx.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (props.mode === "off") return;
|
||||
const sample = sampleImageData(props.getSource(), 256);
|
||||
if (!sample) return;
|
||||
if (props.mode === "hist" || props.mode === "both") drawHistogram(sample.data);
|
||||
if (props.mode === "wave" || props.mode === "both")
|
||||
drawWaveform(sample.data, sample.width, sample.height);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.tick, props.mode],
|
||||
() => requestAnimationFrame(render),
|
||||
);
|
||||
onMounted(render);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mjr-studio-scopes" aria-label="Scopes">
|
||||
<div v-if="mode === 'hist' || mode === 'both'" class="mjr-studio-scopes__panel">
|
||||
<span class="mjr-studio-scopes__label">{{
|
||||
t("viewer.studio.histogram", "Histogram")
|
||||
}}</span>
|
||||
<canvas ref="hist" width="256" height="110" />
|
||||
</div>
|
||||
<div v-if="mode === 'wave' || mode === 'both'" class="mjr-studio-scopes__panel">
|
||||
<span class="mjr-studio-scopes__label">{{
|
||||
t("viewer.studio.waveform", "Waveform")
|
||||
}}</span>
|
||||
<canvas ref="wave" width="256" height="256" class="is-wave" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StudioSlider.vue - compact pointer/keyboard slider for the Studio tool HUD.
|
||||
* Double-click resets to `defaultValue`.
|
||||
*/
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
defaultValue?: number;
|
||||
format?: (value: number) => string;
|
||||
}>(),
|
||||
{ step: 0.01, defaultValue: undefined, format: undefined },
|
||||
);
|
||||
const model = defineModel<number>({ required: true });
|
||||
|
||||
const track = ref<HTMLElement | null>(null);
|
||||
const dragging = ref(false);
|
||||
|
||||
const pct = computed(() => ((model.value - props.min) / (props.max - props.min)) * 100);
|
||||
const display = computed(() => (props.format ? props.format(model.value) : String(model.value)));
|
||||
|
||||
function snap(value: number): number {
|
||||
const stepped = Math.round((value - props.min) / props.step) * props.step + props.min;
|
||||
return Number(Math.max(props.min, Math.min(props.max, stepped)).toFixed(4));
|
||||
}
|
||||
|
||||
function fromPointer(event: PointerEvent) {
|
||||
const rect = track.value?.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0) return;
|
||||
const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
model.value = snap(props.min + ratio * (props.max - props.min));
|
||||
}
|
||||
|
||||
function onDown(event: PointerEvent) {
|
||||
dragging.value = true;
|
||||
(event.currentTarget as HTMLElement)?.setPointerCapture?.(event.pointerId);
|
||||
fromPointer(event);
|
||||
}
|
||||
function onMove(event: PointerEvent) {
|
||||
if (dragging.value) fromPointer(event);
|
||||
}
|
||||
function onUp() {
|
||||
dragging.value = false;
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
const big = event.shiftKey ? 10 : 1;
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowDown")
|
||||
model.value = snap(model.value - props.step * big);
|
||||
else if (event.key === "ArrowRight" || event.key === "ArrowUp")
|
||||
model.value = snap(model.value + props.step * big);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
function reset() {
|
||||
if (props.defaultValue != null) model.value = props.defaultValue;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mjr-studio-slider"
|
||||
:title="defaultValue != null ? 'Double-click to reset' : undefined"
|
||||
@dblclick="reset"
|
||||
>
|
||||
<div class="mjr-studio-slider__head">
|
||||
<span>{{ label }}</span>
|
||||
<span class="mjr-studio-slider__value">{{ display }}</span>
|
||||
</div>
|
||||
<div
|
||||
ref="track"
|
||||
class="mjr-studio-slider__track"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
:aria-label="label"
|
||||
:aria-valuemin="min"
|
||||
:aria-valuemax="max"
|
||||
:aria-valuenow="model"
|
||||
@pointerdown="onDown"
|
||||
@pointermove="onMove"
|
||||
@pointerup="onUp"
|
||||
@pointercancel="onUp"
|
||||
@keydown="onKey"
|
||||
>
|
||||
<div class="mjr-studio-slider__fill" :style="{ width: `${pct}%` }" />
|
||||
<div class="mjr-studio-slider__knob" :style="{ left: `${pct}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StudioToolRail.vue - vertical review-tool HUD on the left of the stage.
|
||||
*
|
||||
* Each icon toggles a tool; tools with settings open a floating panel next
|
||||
* to the rail. Covers the classic viewer's toolset: channels, exposure /
|
||||
* gamma, zebra, scopes, guides, aspect mask, probe, loupe, compare, copy.
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
import { t } from "../../../../app/i18n.js";
|
||||
import {
|
||||
COMPARE_MODES,
|
||||
type StudioAspect,
|
||||
type StudioChannel,
|
||||
type StudioCompare,
|
||||
type StudioGrid,
|
||||
type StudioScopes,
|
||||
type StudioTools,
|
||||
defaultStudioTools,
|
||||
} from "../../../../features/viewer/studio/studioFx.js";
|
||||
import StudioSlider from "./StudioSlider.vue";
|
||||
|
||||
const tools = defineModel<StudioTools>("tools", { required: true });
|
||||
const props = defineProps<{
|
||||
isVideo: boolean;
|
||||
canAnalyse: boolean;
|
||||
canCompare: boolean;
|
||||
compareName?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: "cycle-b", delta: number): void;
|
||||
(e: "pin-b"): void;
|
||||
(e: "swap"): void;
|
||||
(e: "copy"): void;
|
||||
}>();
|
||||
|
||||
type PanelId =
|
||||
"channels" | "exposure" | "zebra" | "scopes" | "grid" | "aspect" | "loupe" | "compare" | null;
|
||||
const panel = ref<PanelId>(null);
|
||||
const panelTop = ref(0);
|
||||
const rail = ref<HTMLElement | null>(null);
|
||||
|
||||
const CHANNELS: { value: StudioChannel; label: string }[] = [
|
||||
{ value: "rgb", label: "RGB" },
|
||||
{ value: "r", label: "R" },
|
||||
{ value: "g", label: "G" },
|
||||
{ value: "b", label: "B" },
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "l", label: "Luma" },
|
||||
];
|
||||
const SCOPES: { value: StudioScopes; label: string }[] = [
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "hist", label: "Histogram" },
|
||||
{ value: "wave", label: "Waveform" },
|
||||
{ value: "both", label: "Both" },
|
||||
];
|
||||
const GRIDS: { value: StudioGrid; label: string }[] = [
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "thirds", label: "Thirds" },
|
||||
{ value: "golden", label: "Golden" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "safe", label: "Safe areas" },
|
||||
];
|
||||
const ASPECTS: { value: StudioAspect; label: string }[] = [
|
||||
{ value: "off", label: "Off" },
|
||||
{ value: "16:9", label: "16:9" },
|
||||
{ value: "9:16", label: "9:16" },
|
||||
{ value: "1:1", label: "1:1" },
|
||||
{ value: "4:3", label: "4:3" },
|
||||
{ value: "2.39", label: "2.39" },
|
||||
];
|
||||
const compareModes = computed(() => COMPARE_MODES.filter((m) => !(props.isVideo && m.imageOnly)));
|
||||
|
||||
const exposureActive = computed(
|
||||
() => Math.abs(tools.value.exposure) > 1e-3 || Math.abs(tools.value.gamma - 1) > 1e-3,
|
||||
);
|
||||
|
||||
function toggle(id: Exclude<PanelId, null>, event: MouseEvent) {
|
||||
if (panel.value === id) {
|
||||
panel.value = null;
|
||||
return;
|
||||
}
|
||||
const button = event.currentTarget as HTMLElement | null;
|
||||
const railTop = rail.value?.getBoundingClientRect().top ?? 0;
|
||||
panelTop.value = Math.max(0, (button?.getBoundingClientRect().top ?? railTop) - railTop);
|
||||
panel.value = id;
|
||||
}
|
||||
|
||||
function resetColor() {
|
||||
const d = defaultStudioTools();
|
||||
tools.value.exposure = d.exposure;
|
||||
tools.value.gamma = d.gamma;
|
||||
}
|
||||
function resetAll() {
|
||||
const d = defaultStudioTools();
|
||||
Object.assign(tools.value, {
|
||||
...d,
|
||||
loupeSize: tools.value.loupeSize,
|
||||
loupeZoom: tools.value.loupeZoom,
|
||||
});
|
||||
panel.value = null;
|
||||
}
|
||||
function setCompare(mode: StudioCompare) {
|
||||
tools.value.compare = tools.value.compare === mode ? "off" : mode;
|
||||
}
|
||||
|
||||
function onDocPointer(event: PointerEvent) {
|
||||
if (!panel.value) return;
|
||||
if (rail.value?.contains(event.target as Node)) return;
|
||||
panel.value = null;
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener("pointerdown", onDocPointer, true);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("pointerdown", onDocPointer, true);
|
||||
});
|
||||
|
||||
const fmtEv = (v: number) => `${v > 0 ? "+" : ""}${v.toFixed(2)} EV`;
|
||||
const fmtGamma = (v: number) => v.toFixed(2);
|
||||
const fmtPct = (v: number) => `${Math.round(v * 100)}%`;
|
||||
const fmtX = (v: number) => `${v}×`;
|
||||
const fmtPx = (v: number) => `${v}px`;
|
||||
|
||||
/** Closes the open settings panel; true when one was open (Escape handling). */
|
||||
function closePanel(): boolean {
|
||||
if (!panel.value) return false;
|
||||
panel.value = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
defineExpose({ closePanel });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rail" class="mjr-studio-rail" @pointerdown.stop @wheel.stop @dblclick.stop>
|
||||
<div
|
||||
class="mjr-studio-rail__bar"
|
||||
role="toolbar"
|
||||
:aria-label="t('viewer.studio.tools', 'Review tools')"
|
||||
>
|
||||
<template v-if="canAnalyse">
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:class="{ 'is-on': tools.channel !== 'rgb', 'is-open': panel === 'channels' }"
|
||||
:title="t('viewer.studio.channels', 'Channels')"
|
||||
@click="toggle('channels', $event)"
|
||||
>
|
||||
<i class="pi pi-palette" aria-hidden="true" />
|
||||
<span v-if="tools.channel !== 'rgb'" class="mjr-studio-rail__tag">{{
|
||||
tools.channel.toUpperCase()
|
||||
}}</span>
|
||||
</MButton>
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:class="{ 'is-on': exposureActive, 'is-open': panel === 'exposure' }"
|
||||
:title="t('viewer.studio.exposure', 'Exposure / gamma')"
|
||||
@click="toggle('exposure', $event)"
|
||||
>
|
||||
<i class="pi pi-bolt" aria-hidden="true" />
|
||||
</MButton>
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:class="{ 'is-on': tools.zebra, 'is-open': panel === 'zebra' }"
|
||||
:title="t('viewer.studio.zebra', 'Zebra highlights (Z)')"
|
||||
@click="toggle('zebra', $event)"
|
||||
>
|
||||
<i class="pi pi-exclamation-triangle" aria-hidden="true" />
|
||||
</MButton>
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:class="{ 'is-on': tools.scopes !== 'off', 'is-open': panel === 'scopes' }"
|
||||
:title="t('viewer.studio.scopes', 'Scopes (S)')"
|
||||
@click="toggle('scopes', $event)"
|
||||
>
|
||||
<i class="pi pi-chart-bar" aria-hidden="true" />
|
||||
</MButton>
|
||||
<span class="mjr-studio-rail__sep" />
|
||||
</template>
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:class="{ 'is-on': tools.grid !== 'off', 'is-open': panel === 'grid' }"
|
||||
:title="t('viewer.studio.grid', 'Guides (G)')"
|
||||
@click="toggle('grid', $event)"
|
||||
>
|
||||
<i class="pi pi-th-large" aria-hidden="true" />
|
||||
</MButton>
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:class="{ 'is-on': tools.aspect !== 'off', 'is-open': panel === 'aspect' }"
|
||||
:title="t('viewer.studio.aspect', 'Aspect ratio mask')"
|
||||
@click="toggle('aspect', $event)"
|
||||
>
|
||||
<i class="pi pi-stop" aria-hidden="true" />
|
||||
</MButton>
|
||||
<template v-if="canAnalyse">
|
||||
<span class="mjr-studio-rail__sep" />
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:class="{ 'is-on': tools.probe }"
|
||||
:title="t('viewer.studio.probe', 'Pixel probe (P)')"
|
||||
@click="tools.probe = !tools.probe"
|
||||
>
|
||||
<i class="pi pi-map-marker" aria-hidden="true" />
|
||||
</MButton>
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:class="{ 'is-on': tools.loupe, 'is-open': panel === 'loupe' }"
|
||||
:title="t('viewer.studio.loupe', 'Loupe (L) - right-click for settings')"
|
||||
@click="tools.loupe = !tools.loupe"
|
||||
@contextmenu.prevent="toggle('loupe', $event)"
|
||||
>
|
||||
<i class="pi pi-search-plus" aria-hidden="true" />
|
||||
</MButton>
|
||||
</template>
|
||||
<template v-if="canCompare">
|
||||
<span class="mjr-studio-rail__sep" />
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:class="{ 'is-on': tools.compare !== 'off', 'is-open': panel === 'compare' }"
|
||||
:title="t('viewer.studio.compareTool', 'Compare A/B (C)')"
|
||||
@click="toggle('compare', $event)"
|
||||
>
|
||||
<i class="pi pi-clone" aria-hidden="true" />
|
||||
</MButton>
|
||||
</template>
|
||||
<span class="mjr-studio-rail__sep" />
|
||||
<MButton
|
||||
v-if="canAnalyse"
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:title="t('viewer.studio.copy', 'Copy image / frame to clipboard')"
|
||||
@click="emit('copy')"
|
||||
>
|
||||
<i class="pi pi-copy" aria-hidden="true" />
|
||||
</MButton>
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-rail__btn"
|
||||
:title="t('viewer.studio.resetTools', 'Reset all tools (R)')"
|
||||
@click="resetAll"
|
||||
>
|
||||
<i class="pi pi-refresh" aria-hidden="true" />
|
||||
</MButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="panel"
|
||||
class="mjr-studio-rail__panel"
|
||||
:style="{ top: `${panelTop}px` }"
|
||||
role="dialog"
|
||||
>
|
||||
<template v-if="panel === 'channels'">
|
||||
<div class="mjr-studio-rail__title">
|
||||
{{ t("viewer.studio.channels", "Channels") }}
|
||||
</div>
|
||||
<div class="mjr-studio-seg">
|
||||
<MButton
|
||||
v-for="c in CHANNELS"
|
||||
:key="c.value"
|
||||
type="button"
|
||||
:class="{ 'is-on': tools.channel === c.value }"
|
||||
@click="tools.channel = c.value"
|
||||
>{{ c.label }}</MButton
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="panel === 'exposure'">
|
||||
<div class="mjr-studio-rail__title">
|
||||
{{ t("viewer.studio.exposure", "Exposure / gamma") }}
|
||||
</div>
|
||||
<StudioSlider
|
||||
v-model="tools.exposure"
|
||||
:label="t('viewer.studio.ev', 'Exposure')"
|
||||
:min="-5"
|
||||
:max="5"
|
||||
:step="0.05"
|
||||
:default-value="0"
|
||||
:format="fmtEv"
|
||||
/>
|
||||
<StudioSlider
|
||||
v-model="tools.gamma"
|
||||
:label="t('viewer.studio.gamma', 'Gamma')"
|
||||
:min="0.1"
|
||||
:max="3"
|
||||
:step="0.01"
|
||||
:default-value="1"
|
||||
:format="fmtGamma"
|
||||
/>
|
||||
<MButton type="button" class="mjr-studio-btn is-text" @click="resetColor">{{
|
||||
t("viewer.studio.reset", "Reset")
|
||||
}}</MButton>
|
||||
</template>
|
||||
<template v-else-if="panel === 'zebra'">
|
||||
<div class="mjr-studio-rail__title">
|
||||
{{ t("viewer.studio.zebra", "Zebra highlights (Z)") }}
|
||||
</div>
|
||||
<div class="mjr-studio-seg">
|
||||
<MButton
|
||||
type="button"
|
||||
:class="{ 'is-on': !tools.zebra }"
|
||||
@click="tools.zebra = false"
|
||||
>Off</MButton
|
||||
>
|
||||
<MButton
|
||||
type="button"
|
||||
:class="{ 'is-on': tools.zebra }"
|
||||
@click="tools.zebra = true"
|
||||
>On</MButton
|
||||
>
|
||||
</div>
|
||||
<StudioSlider
|
||||
v-model="tools.zebraThreshold"
|
||||
:label="t('viewer.studio.threshold', 'Threshold')"
|
||||
:min="0.5"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:default-value="0.95"
|
||||
:format="fmtPct"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="panel === 'scopes'">
|
||||
<div class="mjr-studio-rail__title">
|
||||
{{ t("viewer.studio.scopes", "Scopes (S)") }}
|
||||
</div>
|
||||
<div class="mjr-studio-seg is-wrap">
|
||||
<MButton
|
||||
v-for="s in SCOPES"
|
||||
:key="s.value"
|
||||
type="button"
|
||||
:class="{ 'is-on': tools.scopes === s.value }"
|
||||
@click="tools.scopes = s.value"
|
||||
>{{ s.label }}</MButton
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="panel === 'grid'">
|
||||
<div class="mjr-studio-rail__title">
|
||||
{{ t("viewer.studio.grid", "Guides (G)") }}
|
||||
</div>
|
||||
<div class="mjr-studio-seg is-wrap">
|
||||
<MButton
|
||||
v-for="g in GRIDS"
|
||||
:key="g.value"
|
||||
type="button"
|
||||
:class="{ 'is-on': tools.grid === g.value }"
|
||||
@click="tools.grid = g.value"
|
||||
>{{ g.label }}</MButton
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="panel === 'aspect'">
|
||||
<div class="mjr-studio-rail__title">
|
||||
{{ t("viewer.studio.aspect", "Aspect ratio mask") }}
|
||||
</div>
|
||||
<div class="mjr-studio-seg is-wrap">
|
||||
<MButton
|
||||
v-for="a in ASPECTS"
|
||||
:key="a.value"
|
||||
type="button"
|
||||
:class="{ 'is-on': tools.aspect === a.value }"
|
||||
@click="tools.aspect = a.value"
|
||||
>{{ a.label }}</MButton
|
||||
>
|
||||
</div>
|
||||
<StudioSlider
|
||||
v-model="tools.maskOpacity"
|
||||
:label="t('viewer.studio.opacity', 'Opacity')"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.05"
|
||||
:default-value="0.65"
|
||||
:format="fmtPct"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="panel === 'loupe'">
|
||||
<div class="mjr-studio-rail__title">
|
||||
{{ t("viewer.studio.loupeTitle", "Loupe") }}
|
||||
</div>
|
||||
<StudioSlider
|
||||
v-model="tools.loupeZoom"
|
||||
:label="t('viewer.studio.magnification', 'Magnification')"
|
||||
:min="2"
|
||||
:max="32"
|
||||
:step="1"
|
||||
:default-value="8"
|
||||
:format="fmtX"
|
||||
/>
|
||||
<StudioSlider
|
||||
v-model="tools.loupeSize"
|
||||
:label="t('viewer.studio.size', 'Size')"
|
||||
:min="80"
|
||||
:max="320"
|
||||
:step="10"
|
||||
:default-value="140"
|
||||
:format="fmtPx"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="panel === 'compare'">
|
||||
<div class="mjr-studio-rail__title">
|
||||
{{ t("viewer.studio.compareTool", "Compare A/B (C)") }}
|
||||
</div>
|
||||
<div class="mjr-studio-seg is-wrap">
|
||||
<MButton
|
||||
v-for="m in compareModes"
|
||||
:key="m.value"
|
||||
type="button"
|
||||
:class="{ 'is-on': tools.compare === m.value }"
|
||||
@click="setCompare(m.value)"
|
||||
>{{ m.label }}</MButton
|
||||
>
|
||||
</div>
|
||||
<div class="mjr-studio-rail__b">
|
||||
<span class="mjr-studio-rail__b-label">B</span>
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-btn"
|
||||
:title="t('viewer.studio.prevB', 'Previous B')"
|
||||
@click="emit('cycle-b', -1)"
|
||||
><i class="pi pi-chevron-left" aria-hidden="true"
|
||||
/></MButton>
|
||||
<span class="mjr-studio-rail__b-name" :title="compareName">{{
|
||||
compareName || "—"
|
||||
}}</span>
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-btn"
|
||||
:title="t('viewer.studio.nextB', 'Next B')"
|
||||
@click="emit('cycle-b', 1)"
|
||||
><i class="pi pi-chevron-right" aria-hidden="true"
|
||||
/></MButton>
|
||||
</div>
|
||||
<div class="mjr-studio-seg">
|
||||
<MButton
|
||||
type="button"
|
||||
:title="t('viewer.studio.pinB', 'Use the current asset as B (B)')"
|
||||
@click="emit('pin-b')"
|
||||
><i class="pi pi-bookmark" aria-hidden="true" />
|
||||
{{ t("viewer.studio.pinBShort", "Set B") }}</MButton
|
||||
>
|
||||
<MButton
|
||||
type="button"
|
||||
:title="t('viewer.studio.swap', 'Swap A and B (X)')"
|
||||
@click="emit('swap')"
|
||||
><i class="pi pi-arrow-right-arrow-left" aria-hidden="true" />
|
||||
{{ t("viewer.studio.swapShort", "Swap") }}</MButton
|
||||
>
|
||||
</div>
|
||||
<StudioSlider
|
||||
v-if="tools.compare === 'wipe' || tools.compare === 'wipeV'"
|
||||
v-model="tools.wipe"
|
||||
:label="t('viewer.studio.wipe', 'Wipe')"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
:default-value="50"
|
||||
:format="(v: number) => `${v}%`"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,11 +6,13 @@
|
||||
* inspector (review, generation data, workflow, file) / filmstrip.
|
||||
*
|
||||
* It reuses the asset sidebar's components for generation metadata, workflow,
|
||||
* rating and tags, so the data shown is identical everywhere. Compare modes
|
||||
* (A/B, side-by-side) and 3D models stay in the classic viewer, reached
|
||||
* through the `open-classic` event.
|
||||
* rating and tags, so the data shown is identical everywhere.
|
||||
*
|
||||
* Review tools live in a HUD rail on the left of the stage (channels,
|
||||
* exposure/gamma, zebra, scopes, guides, aspect mask, probe, loupe, A/B
|
||||
* compare, copy). The classic viewer stays reachable through `open-classic`.
|
||||
*/
|
||||
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, shallowRef, watch } from "vue";
|
||||
|
||||
import {
|
||||
getAssetMetadata,
|
||||
@@ -23,6 +25,14 @@ import { ASSET_RATING_CHANGED_EVENT } from "../../../../app/events.js";
|
||||
import { t } from "../../../../app/i18n.js";
|
||||
import { comfyToast } from "../../../../app/toast.js";
|
||||
import { ensureViewerMetadataAsset } from "../../../../features/viewer/genInfo.js";
|
||||
import {
|
||||
defaultStudioTools,
|
||||
hasColorFx,
|
||||
nextCompareIndex,
|
||||
type StudioGrid,
|
||||
type StudioScopes,
|
||||
} from "../../../../features/viewer/studio/studioFx.js";
|
||||
import { sourceToPngBlob } from "../../../../features/viewer/studio/studioSampler.js";
|
||||
import {
|
||||
assetSummary,
|
||||
filmstripWindow,
|
||||
@@ -40,8 +50,15 @@ import TagsEditor from "../../common/TagsEditor.vue";
|
||||
import SidebarFileInfoSection from "../../panel/sidebar/SidebarFileInfoSection.vue";
|
||||
import SidebarGenerationSection from "../../panel/sidebar/SidebarGenerationSection.vue";
|
||||
import SidebarWorkflowSection from "../../panel/sidebar/SidebarWorkflowSection.vue";
|
||||
import StudioFxDefs from "./StudioFxDefs.vue";
|
||||
import StudioGuides from "./StudioGuides.vue";
|
||||
import StudioImageStage from "./StudioImageStage.vue";
|
||||
import StudioModel3D from "./StudioModel3D.vue";
|
||||
import StudioPlayer from "./StudioPlayer.vue";
|
||||
import StudioProbe from "./StudioProbe.vue";
|
||||
import StudioScopesHud from "./StudioScopes.vue";
|
||||
import StudioToolRail from "./StudioToolRail.vue";
|
||||
import StudioZebra from "./StudioZebra.vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "open-classic", payload: { assets: unknown[]; index: number; mode: string }): void;
|
||||
@@ -52,6 +69,7 @@ type InspectorTab = "generation" | "workflow" | "file";
|
||||
const overlay = ref<HTMLElement | null>(null);
|
||||
const imageStage = ref<InstanceType<typeof StudioImageStage> | null>(null);
|
||||
const player = ref<InstanceType<typeof StudioPlayer> | null>(null);
|
||||
const toolRail = ref<InstanceType<typeof StudioToolRail> | null>(null);
|
||||
const inspectorOpen = ref(true);
|
||||
const tab = ref<InspectorTab>("generation");
|
||||
const checker = ref(false);
|
||||
@@ -76,7 +94,156 @@ const filmstrip = computed(() => {
|
||||
.slice(start, end)
|
||||
.map((a, i) => ({ asset: a, index: start + i }));
|
||||
});
|
||||
const canCompare = computed(() => count.value >= 2);
|
||||
|
||||
// ── Review tools (HUD rail) ────────────────────────────────────────────
|
||||
const tools = reactive(defaultStudioTools());
|
||||
const filterId = `mjr-studio-fx-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const compareIndex = ref<number | null>(null);
|
||||
const tick = ref(0);
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastFrameTime = -1;
|
||||
|
||||
const canAnalyse = computed(() => kind.value === "image" || kind.value === "video");
|
||||
const bIndex = computed(() => {
|
||||
if (!canAnalyse.value) return null;
|
||||
const pinned = compareIndex.value;
|
||||
const list = studioViewerState.assets;
|
||||
const current = studioViewerState.index;
|
||||
if (
|
||||
pinned != null &&
|
||||
pinned !== current &&
|
||||
list[pinned] &&
|
||||
studioMediaKind(list[pinned]) === kind.value
|
||||
)
|
||||
return pinned;
|
||||
return nextCompareIndex(list, current, 1, (a) => studioMediaKind(a) === kind.value);
|
||||
});
|
||||
const canCompare = computed(() => bIndex.value != null);
|
||||
const compareAsset = computed(() =>
|
||||
bIndex.value != null ? studioViewerState.assets[bIndex.value] : null,
|
||||
);
|
||||
const comparing = computed(() => tools.compare !== "off" && !!compareAsset.value);
|
||||
const compareSrc = computed(() =>
|
||||
comparing.value && compareAsset.value ? buildAssetViewURL(compareAsset.value) : null,
|
||||
);
|
||||
const subtractSrc = computed(() =>
|
||||
comparing.value && tools.compare === "subtract" && kind.value === "image"
|
||||
? compareSrc.value
|
||||
: null,
|
||||
);
|
||||
const mediaFilter = computed(() =>
|
||||
canAnalyse.value && (hasColorFx(tools) || subtractSrc.value) ? `url(#${filterId})` : "",
|
||||
);
|
||||
const needsTick = computed(() => canAnalyse.value && (tools.zebra || tools.scopes !== "off"));
|
||||
|
||||
function getSource() {
|
||||
if (kind.value === "image") return imageStage.value?.getSource?.() ?? null;
|
||||
if (kind.value === "video") return player.value?.getSource?.() ?? null;
|
||||
return null;
|
||||
}
|
||||
function getBox() {
|
||||
if (kind.value === "image") return imageStage.value?.getBox?.() ?? null;
|
||||
if (kind.value === "video") return player.value?.getBox?.() ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Re-sample zebra / scopes: on image load, and ~8 fps while a video frame changes. */
|
||||
function bumpTick() {
|
||||
tick.value += 1;
|
||||
}
|
||||
function stopTicker() {
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
}
|
||||
watch(
|
||||
[needsTick, kind, () => studioViewerState.open],
|
||||
([need, k, open]) => {
|
||||
stopTicker();
|
||||
if (!need || !open || k !== "video") return;
|
||||
lastFrameTime = -1;
|
||||
tickTimer = setInterval(() => {
|
||||
const v = getSource() as HTMLVideoElement | null;
|
||||
const time = v?.currentTime ?? -1;
|
||||
if (time !== lastFrameTime) {
|
||||
lastFrameTime = time;
|
||||
bumpTick();
|
||||
}
|
||||
}, 120);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(needsTick, (need) => {
|
||||
if (need) void nextTick(bumpTick);
|
||||
});
|
||||
|
||||
function cycleB(delta: number) {
|
||||
const from = bIndex.value ?? studioViewerState.index;
|
||||
const next = nextCompareIndex(
|
||||
studioViewerState.assets,
|
||||
from,
|
||||
delta,
|
||||
(a, i) => i !== studioViewerState.index && studioMediaKind(a) === kind.value,
|
||||
);
|
||||
if (next != null) compareIndex.value = next;
|
||||
}
|
||||
function pinB() {
|
||||
compareIndex.value = studioViewerState.index;
|
||||
const next = nextCompareIndex(
|
||||
studioViewerState.assets,
|
||||
studioViewerState.index,
|
||||
1,
|
||||
(a) => studioMediaKind(a) === kind.value,
|
||||
);
|
||||
if (next != null) goToStudioIndex(next);
|
||||
if (tools.compare === "off") tools.compare = "wipe";
|
||||
}
|
||||
function swapAB() {
|
||||
const b = bIndex.value;
|
||||
if (b == null) return;
|
||||
compareIndex.value = studioViewerState.index;
|
||||
goToStudioIndex(b);
|
||||
}
|
||||
function toggleCompare() {
|
||||
if (!canCompare.value) return;
|
||||
tools.compare = tools.compare === "off" ? "wipe" : "off";
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
const blob = await sourceToPngBlob(getSource());
|
||||
const ClipboardItemCtor = (window as any).ClipboardItem;
|
||||
if (!blob || !ClipboardItemCtor || !navigator.clipboard?.write) {
|
||||
comfyToast(t("viewer.studio.copyFailed", "Could not copy this image"), "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItemCtor({ "image/png": blob })]);
|
||||
comfyToast(t("viewer.studio.copied", "Copied to clipboard"), "success");
|
||||
} catch (e) {
|
||||
console.debug?.(e);
|
||||
comfyToast(t("viewer.studio.copyFailed", "Could not copy this image"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
const SCOPE_CYCLE: StudioScopes[] = ["off", "hist", "wave", "both"];
|
||||
const GRID_CYCLE: StudioGrid[] = ["off", "thirds", "golden", "center", "safe"];
|
||||
function cycle<T>(list: T[], value: T): T {
|
||||
return list[(list.indexOf(value) + 1) % list.length];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => studioViewerState.session,
|
||||
() => {
|
||||
compareIndex.value = null;
|
||||
tools.compare = studioViewerState.compare || "off";
|
||||
// A new open request while already open: take focus back from editors.
|
||||
void nextTick(() => {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (active && overlay.value?.contains(active) && isTypingTarget(active)) active.blur();
|
||||
overlay.value?.focus?.({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function hydrate(target: any) {
|
||||
hydrateAbort?.abort();
|
||||
@@ -121,6 +288,7 @@ watch(
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
} else {
|
||||
window.removeEventListener("keydown", onKeydown, true);
|
||||
stopTicker();
|
||||
player.value?.release?.();
|
||||
hydrateAbort?.abort();
|
||||
if (document.fullscreenElement && overlay.value?.contains(document.fullscreenElement)) {
|
||||
@@ -133,6 +301,7 @@ watch(
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("keydown", onKeydown, true);
|
||||
stopTicker();
|
||||
hydrateAbort?.abort();
|
||||
});
|
||||
|
||||
@@ -194,8 +363,15 @@ function onKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
const isMedia = kind.value === "video" || kind.value === "audio";
|
||||
const key = event.key;
|
||||
const lower = key.length === 1 ? key.toLowerCase() : key;
|
||||
const plain = !event.ctrlKey && !event.metaKey && !event.altKey;
|
||||
const tool = canAnalyse.value && plain;
|
||||
let handled = true;
|
||||
if (key === "Escape") close();
|
||||
if (key === "Escape" && toolRail.value?.closePanel?.()) {
|
||||
/* closed the open tool panel */
|
||||
} else if (key === "Escape") close();
|
||||
else if (lower === "c" && (event.ctrlKey || event.metaKey) && canAnalyse.value)
|
||||
void copyToClipboard();
|
||||
else if (key === "ArrowLeft" && !event.shiftKey) stepStudioViewer(-1);
|
||||
else if (key === "ArrowRight" && !event.shiftKey) stepStudioViewer(1);
|
||||
else if (key === "ArrowLeft" && event.shiftKey && isMedia) player.value?.seekBy?.(-5);
|
||||
@@ -219,7 +395,19 @@ function onKeydown(event: KeyboardEvent) {
|
||||
else if (key === "0" && kind.value === "image" && event.altKey) imageStage.value?.fit?.();
|
||||
else if (/^[0-5]$/.test(key) && !event.ctrlKey && !event.metaKey && !event.altKey)
|
||||
void setRating(Number(key));
|
||||
else if ((key === "c" || key === "C") && canCompare.value) openClassic("ab");
|
||||
else if (lower === "c" && plain && canCompare.value) toggleCompare();
|
||||
else if (lower === "b" && plain && canCompare.value) pinB();
|
||||
else if (lower === "x" && plain && comparing.value) swapAB();
|
||||
else if (lower === "z" && tool) tools.zebra = !tools.zebra;
|
||||
else if (lower === "s" && tool) tools.scopes = cycle(SCOPE_CYCLE, tools.scopes);
|
||||
else if (lower === "g" && plain && canAnalyse.value) tools.grid = cycle(GRID_CYCLE, tools.grid);
|
||||
else if (lower === "p" && tool) tools.probe = !tools.probe;
|
||||
else if (lower === "l" && tool) tools.loupe = !tools.loupe;
|
||||
else if (lower === "r" && tool)
|
||||
Object.assign(tools, defaultStudioTools(), {
|
||||
loupeSize: tools.loupeSize,
|
||||
loupeZoom: tools.loupeZoom,
|
||||
});
|
||||
else handled = false;
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
@@ -318,8 +506,9 @@ function thumbUrl(a: any): string {
|
||||
v-if="canCompare"
|
||||
type="button"
|
||||
class="mjr-studio-btn"
|
||||
:title="t('viewer.studio.compare', 'Compare with next (C)')"
|
||||
@click="openClassic('ab')"
|
||||
:class="{ 'is-on': comparing }"
|
||||
:title="t('viewer.studio.compare', 'Compare A/B (C)')"
|
||||
@click="toggleCompare"
|
||||
>
|
||||
<i class="pi pi-clone" aria-hidden="true" />
|
||||
</MButton>
|
||||
@@ -334,12 +523,7 @@ function thumbUrl(a: any): string {
|
||||
<MButton
|
||||
type="button"
|
||||
class="mjr-studio-btn"
|
||||
:title="
|
||||
t(
|
||||
'viewer.studio.classic',
|
||||
'Open in classic viewer (tools: scopes, loupe, grading)',
|
||||
)
|
||||
"
|
||||
:title="t('viewer.studio.classic', 'Open in the classic viewer')"
|
||||
@click="openClassic('')"
|
||||
>
|
||||
<i class="pi pi-sliders-h" aria-hidden="true" />
|
||||
@@ -373,25 +557,82 @@ function thumbUrl(a: any): string {
|
||||
</header>
|
||||
|
||||
<main class="mjr-studio__body">
|
||||
<section class="mjr-studio__stage">
|
||||
<section class="mjr-studio__stage" :class="{ 'has-rail': canAnalyse }">
|
||||
<StudioFxDefs
|
||||
v-if="mediaFilter"
|
||||
:filter-id="filterId"
|
||||
:channel="tools.channel"
|
||||
:exposure="tools.exposure"
|
||||
:gamma="tools.gamma"
|
||||
:subtract-src="subtractSrc"
|
||||
/>
|
||||
<StudioImageStage
|
||||
v-if="kind === 'image'"
|
||||
ref="imageStage"
|
||||
:key="`img:${mediaUrl}`"
|
||||
v-model:wipe="tools.wipe"
|
||||
:src="mediaUrl"
|
||||
:alt="asset?.filename"
|
||||
:checker="checker"
|
||||
:media-filter="mediaFilter"
|
||||
:compare-src="compareSrc"
|
||||
:compare="tools.compare"
|
||||
:class="{ 'is-probing': tools.probe || tools.loupe }"
|
||||
@zoom="zoom = $event"
|
||||
/>
|
||||
@loaded="bumpTick"
|
||||
>
|
||||
<template #overlay="{ width, height }">
|
||||
<StudioZebra
|
||||
v-if="tools.zebra"
|
||||
:get-source="getSource"
|
||||
:threshold="tools.zebraThreshold"
|
||||
:tick="tick"
|
||||
/>
|
||||
<StudioGuides
|
||||
:width="width"
|
||||
:height="height"
|
||||
:grid="tools.grid"
|
||||
:aspect="tools.aspect"
|
||||
:mask-opacity="tools.maskOpacity"
|
||||
/>
|
||||
</template>
|
||||
</StudioImageStage>
|
||||
<StudioPlayer
|
||||
v-else-if="kind === 'video' || kind === 'audio'"
|
||||
ref="player"
|
||||
:key="`media:${mediaUrl}`"
|
||||
v-model:wipe="tools.wipe"
|
||||
:src="mediaUrl"
|
||||
:kind="kind"
|
||||
:fps="fps"
|
||||
:frame-count="frameCount"
|
||||
:filename="asset?.filename"
|
||||
:media-filter="mediaFilter"
|
||||
:compare-src="compareSrc"
|
||||
:compare="tools.compare"
|
||||
:class="{ 'is-probing': tools.probe || tools.loupe }"
|
||||
>
|
||||
<template #overlay="{ width, height }">
|
||||
<StudioZebra
|
||||
v-if="tools.zebra"
|
||||
:get-source="getSource"
|
||||
:threshold="tools.zebraThreshold"
|
||||
:tick="tick"
|
||||
/>
|
||||
<StudioGuides
|
||||
:width="width"
|
||||
:height="height"
|
||||
:grid="tools.grid"
|
||||
:aspect="tools.aspect"
|
||||
:mask-opacity="tools.maskOpacity"
|
||||
/>
|
||||
</template>
|
||||
</StudioPlayer>
|
||||
<StudioModel3D
|
||||
v-else-if="kind === 'model3d'"
|
||||
:key="`3d:${mediaUrl}`"
|
||||
:asset="asset"
|
||||
:src="mediaUrl"
|
||||
/>
|
||||
<div v-else class="mjr-studio__unsupported">
|
||||
<i class="pi pi-file" aria-hidden="true" />
|
||||
@@ -406,6 +647,36 @@ function thumbUrl(a: any): string {
|
||||
{{ t("viewer.studio.classicShort", "Classic viewer") }}
|
||||
</MButton>
|
||||
</div>
|
||||
|
||||
<StudioToolRail
|
||||
v-if="canAnalyse"
|
||||
ref="toolRail"
|
||||
v-model:tools="tools"
|
||||
:is-video="kind === 'video'"
|
||||
:can-analyse="canAnalyse"
|
||||
:can-compare="canCompare"
|
||||
:compare-name="compareAsset?.filename"
|
||||
@cycle-b="cycleB"
|
||||
@pin-b="pinB"
|
||||
@swap="swapAB"
|
||||
@copy="copyToClipboard"
|
||||
/>
|
||||
<StudioScopesHud
|
||||
v-if="canAnalyse && tools.scopes !== 'off'"
|
||||
:get-source="getSource"
|
||||
:mode="tools.scopes"
|
||||
:tick="tick"
|
||||
/>
|
||||
<StudioProbe
|
||||
v-if="canAnalyse && (tools.probe || tools.loupe)"
|
||||
:get-source="getSource"
|
||||
:get-box="getBox"
|
||||
:probe="tools.probe"
|
||||
:loupe="tools.loupe"
|
||||
:loupe-size="tools.loupeSize"
|
||||
:loupe-zoom="tools.loupeZoom"
|
||||
:media-filter="subtractSrc ? '' : mediaFilter"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside v-if="inspectorOpen && shown" class="mjr-studio__inspector" @keydown.stop>
|
||||
@@ -421,7 +692,12 @@ function thumbUrl(a: any): string {
|
||||
:size="40"
|
||||
/>
|
||||
</div>
|
||||
<TagsEditor :key="`t:${shown.id}`" :asset="shown" :model-value="tags" />
|
||||
<TagsEditor
|
||||
:key="`t:${shown.id}`"
|
||||
:asset="shown"
|
||||
:model-value="tags"
|
||||
:autofocus="false"
|
||||
/>
|
||||
</div>
|
||||
<nav class="mjr-studio__tabs" role="tablist">
|
||||
<MButton
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
/** StudioZebra.vue - stripes over near-white areas (exposure check). */
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
|
||||
import { computeZebra } from "../../../../features/viewer/studio/studioFx.js";
|
||||
import {
|
||||
sampleImageData,
|
||||
type StudioSource,
|
||||
} from "../../../../features/viewer/studio/studioSampler.js";
|
||||
|
||||
const props = defineProps<{
|
||||
getSource: () => StudioSource | null;
|
||||
threshold: number;
|
||||
tick: number;
|
||||
}>();
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
let phase = 0;
|
||||
|
||||
function render() {
|
||||
const c = canvas.value;
|
||||
const sample = sampleImageData(props.getSource(), 480);
|
||||
if (!c || !sample) return;
|
||||
c.width = sample.width;
|
||||
c.height = sample.height;
|
||||
phase = (phase + 1) % 8;
|
||||
const overlay = computeZebra(sample.data, sample.width, sample.height, props.threshold, phase);
|
||||
c.getContext("2d")?.putImageData(new ImageData(overlay, sample.width, sample.height), 0, 0);
|
||||
}
|
||||
|
||||
watch(() => [props.tick, props.threshold], render);
|
||||
onMounted(render);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvas" class="mjr-studio-zebra" aria-hidden="true" />
|
||||
</template>
|
||||
Reference in New Issue
Block a user