// ==UserScript==
// @name Civitai Theater - Fullscreen Media Slideshow & 4K Viewer
// @namespace https://hoangyell.com
// @version 2.2.0
// @description Fullscreen cinema slideshow & 4K viewer for Civitai & Decomfy with 3-panel stage, pan-zoom, filmstrip, and prompt inspector.
// @author Hoang Yell (ya)
// @match https://civitai.com/*
// @match https://civitai.red/*
// @match https://civitai.blue/*
// @match https://*.civitai.com/*
// @match https://decomfy.com/*
// @match https://*.decomfy.com/*
// @run-at document-idle
// @grant none
// ==/UserScript==
(function () {
'use strict';
if (window.__CIVITAI_THEATER_INITIALIZED__) return;
window.__CIVITAI_THEATER_INITIALIZED__ = true;
// --- 100% Vector Lucide SVGs (Zero Raw Emojis) ---
const ICONS = {
play: ``,
pause: ``,
chevronLeft: ``,
chevronRight: ``,
shuffle: ``,
maximize: ``,
minimize: ``,
volume2: ``,
volumeX: ``,
download: ``,
externalLink: ``,
info: ``,
close: ``,
copy: ``,
check: ``,
film: ``,
sparkles: ``,
filmstrip: ``,
zoomIn: ``,
eye: ``,
share: ``,
loader: ``
};
// --- Apple VisionOS & Linear Minimalist Stylesheet ---
const STYLES = `
:root {
--ct-bg: #06080d;
--ct-surface: rgba(13, 17, 28, 0.72);
--ct-surface-card: rgba(18, 24, 38, 0.65);
--ct-border: rgba(255, 255, 255, 0.09);
--ct-border-hover: rgba(255, 255, 255, 0.22);
--ct-text: #f8fafc;
--ct-text-muted: #94a3b8;
--ct-text-dim: #64748b;
--ct-accent: #38bdf8;
--ct-accent-glow: rgba(56, 189, 248, 0.35);
--ct-font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, sans-serif;
--ct-mono: "SF Mono", Monaco, Consolas, "Liberation Mono", Courier, monospace;
}
/* Screen lock during active theater */
html.ct-open-locked, body.ct-open-locked {
overflow: hidden !important;
scrollbar-width: none !important;
}
html.ct-open-locked::-webkit-scrollbar, body.ct-open-locked::-webkit-scrollbar {
display: none !important;
}
/* Floating Launcher Button on Civitai Feed */
#ct-launcher-btn {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 999990;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
background: rgba(10, 15, 26, 0.85);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 9999px;
color: #f8fafc;
font-family: var(--ct-font);
font-size: 13px;
font-weight: 600;
letter-spacing: -0.015em;
cursor: pointer;
box-shadow: 0 20px 35px -8px rgba(0, 0, 0, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.15);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
#ct-launcher-btn:hover {
background: rgba(17, 24, 39, 0.95);
border-color: rgba(56, 189, 248, 0.4);
transform: translateY(-2px) scale(1.02);
box-shadow: 0 24px 40px -10px rgba(0, 0, 0, 0.8), 0 0 20px var(--ct-accent-glow);
}
#ct-launcher-btn:active {
transform: translateY(0) scale(0.98);
}
#ct-launcher-btn svg {
color: var(--ct-accent);
flex-shrink: 0;
}
.ct-source-tag, .ct-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 9999px;
background: rgba(56, 189, 248, 0.15);
border: 1px solid rgba(56, 189, 248, 0.3);
color: var(--ct-accent);
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
}
/* Main Theater Overlay Container */
#ct-overlay {
position: fixed;
inset: 0;
z-index: 9999999;
background: var(--ct-bg);
color: var(--ct-text);
font-family: var(--ct-font);
display: none !important;
visibility: hidden !important;
opacity: 0;
pointer-events: none !important;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
transition: opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
#ct-overlay.ct-active {
display: flex !important;
visibility: visible !important;
opacity: 1;
pointer-events: auto !important;
}
/* Dynamic Multi-Layer Ambient Glow Canvas */
.ct-ambient-glow {
position: absolute;
inset: -50px;
background-size: cover;
background-position: center;
filter: blur(24px) brightness(0.25) saturate(1.3);
opacity: 0.8;
transform: scale(1.08);
z-index: 0;
pointer-events: none;
}
.ct-vignette {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, transparent 40%, rgba(6, 8, 13, 0.88) 100%);
z-index: 1;
pointer-events: none;
}
/* Media Presentation Stage */
.ct-stage {
position: relative;
z-index: 2;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
cursor: default;
overflow: hidden;
gap: 0;
}
.ct-media-canvas {
position: relative;
z-index: 5;
max-width: 100vw;
max-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
transition: transform 0.15s ease-out;
will-change: transform;
}
/* Guarantee 100% Full Height for portrait / tall media */
.ct-media-canvas.ct-tall-media {
height: 100vh;
max-height: 100vh;
width: auto;
max-width: 100vw;
}
.ct-media-canvas.ct-tall-media .ct-media-img,
.ct-media-canvas.ct-tall-media .ct-media-video {
height: 100vh;
max-height: 100vh;
width: auto;
max-width: 100vw;
object-fit: contain;
}
/* Guarantee 100% Full Width for landscape / wide media */
.ct-media-canvas.ct-wide-media {
width: 100vw;
max-width: 100vw;
height: auto;
max-height: 100vh;
}
.ct-media-canvas.ct-wide-media .ct-media-img,
.ct-media-canvas.ct-wide-media .ct-media-video {
width: 100vw;
max-width: 100vw;
height: auto;
max-height: 100vh;
object-fit: contain;
}
.ct-media-img {
max-width: 100vw;
max-height: 100vh;
width: auto;
height: auto;
object-fit: contain;
border-radius: 4px;
display: block;
box-shadow: 0 35px 80px -10px rgba(0, 0, 0, 0.95);
opacity: 0;
transition: opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.ct-media-img.ct-loaded {
opacity: 1;
}
.ct-media-img.ct-thumbnail-preview {
position: absolute;
inset: 0;
margin: auto;
width: 100%;
height: 100%;
object-fit: contain;
filter: blur(10px);
transform: scale(0.98);
opacity: 0.75;
z-index: 1;
transition: opacity 0.4s ease;
}
.ct-media-video {
max-width: 100vw;
max-height: 100vh;
width: auto;
height: auto;
object-fit: contain;
border-radius: 4px;
display: block;
box-shadow: 0 35px 80px -10px rgba(0, 0, 0, 0.95);
}
.ct-stage-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 15;
pointer-events: none;
opacity: 0;
color: var(--ct-accent);
transition: opacity 0.2s ease;
}
.ct-stage-loader.ct-visible {
opacity: 1;
}
.ct-stage-loader svg {
animation: ct-spin 0.85s linear infinite;
}
@keyframes ct-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Widescreen Cinema Flanks (Before & After Preview) */
.ct-flank {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
z-index: 4;
flex: 1 1 0;
min-width: 0;
height: 100vh;
padding: 0 16px;
box-sizing: border-box;
overflow: hidden;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
transition: opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.ct-flank-prev {
order: 1;
}
.ct-media-canvas {
order: 2;
}
.ct-flank-next {
order: 3;
}
@media (min-width: 1024px) {
.ct-stage.ct-has-flanks .ct-flank {
display: flex;
}
}
.ct-flank-preview {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
}
.ct-flank-img {
max-height: calc(100vh - 80px);
max-width: 90%;
width: auto;
height: auto;
object-fit: contain;
border-radius: 8px;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
opacity: 0.38;
filter: brightness(0.65) contrast(0.95);
transform: scale(0.92);
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform, opacity, filter;
}
.ct-flank:hover .ct-flank-img {
opacity: 0.92;
filter: brightness(1.05) contrast(1.02);
transform: scale(0.96);
border-color: rgba(56, 189, 248, 0.4);
box-shadow: 0 30px 70px rgba(0, 0, 0, 0.95), 0 0 25px rgba(56, 189, 248, 0.25);
}
.ct-flank-badge {
position: absolute;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 9999px;
background: rgba(15, 23, 42, 0.75);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--ct-text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
opacity: 0.7;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.25s ease;
z-index: 2;
pointer-events: none;
}
.ct-flank-badge svg {
color: var(--ct-accent);
flex-shrink: 0;
}
.ct-flank:hover .ct-flank-badge {
opacity: 1;
color: #f8fafc;
border-color: rgba(56, 189, 248, 0.4);
background: rgba(15, 23, 42, 0.9);
transform: translateX(-50%) translateY(-2px);
}
/* Hide flanks during Zoom, Inspector, or Zen */
.ct-stage.ct-stage-zoomed .ct-flank {
opacity: 0 !important;
pointer-events: none !important;
transform: scale(0.8);
}
.ct-overlay.ct-inspector-open .ct-flank {
display: none !important;
}
.ct-hud-hidden .ct-flank-badge {
opacity: 0 !important;
pointer-events: none !important;
}
.ct-overlay.ct-filmstrip-open .ct-flank {
height: calc(100vh - 220px);
}
.ct-overlay.ct-filmstrip-open .ct-flank-img {
max-height: 100%;
}
/* Zoom Indicator Pill */
.ct-zoom-hud {
position: absolute;
top: 64px;
right: 24px;
z-index: 12;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(10, 14, 23, 0.7);
border: 1px solid var(--ct-border);
border-radius: 9999px;
font-size: 11px;
font-weight: 600;
color: var(--ct-text-muted);
backdrop-filter: blur(16px);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.ct-zoom-hud.ct-visible {
opacity: 1;
transform: translateY(0);
}
/* Click Targets for Left / Right Slide Navigation */
.ct-hit-prev, .ct-hit-next {
position: absolute;
top: 0;
bottom: 0;
width: 20%;
z-index: 3;
cursor: pointer;
}
.ct-hit-prev { left: 0; }
.ct-hit-next { right: 0; }
/* Floating Side Navigation Arrows */
.ct-edge-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 6;
width: 46px;
height: 46px;
border-radius: 9999px;
background: rgba(12, 16, 26, 0.6);
border: 1px solid var(--ct-border);
color: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 12px 25px -4px rgba(0, 0, 0, 0.6);
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
opacity: 0.55;
}
.ct-edge-arrow:hover {
opacity: 1;
background: rgba(18, 24, 38, 0.9);
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-50%) scale(1.08);
box-shadow: 0 16px 30px -4px rgba(0, 0, 0, 0.7);
}
.ct-edge-arrow.ct-prev { left: 24px; }
.ct-edge-arrow.ct-next { right: 24px; }
/* Top Timeline & Brand Header */
.ct-top-header {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
flex-direction: column;
transition: opacity 0.35s ease, transform 0.35s ease;
}
.ct-progress-track {
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.06);
position: relative;
}
.ct-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #0284c7 0%, #38bdf8 100%);
box-shadow: 0 0 10px var(--ct-accent-glow);
transition: width 0.08s linear;
}
.ct-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: linear-gradient(180deg, rgba(6, 8, 13, 0.9) 0%, rgba(6, 8, 13, 0) 100%);
}
.ct-brand-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
font-weight: 600;
letter-spacing: -0.015em;
color: #f1f5f9;
}
.ct-brand-meta svg {
color: var(--ct-accent);
}
.ct-counter-pill {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--ct-text-muted);
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--ct-border);
padding: 3px 10px;
border-radius: 9999px;
}
.ct-quality-tag {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
padding: 2px 7px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--ct-border);
color: #cbd5e1;
}
.ct-civitai-pill {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
color: #38bdf8;
background: rgba(56, 189, 248, 0.12);
border: 1px solid rgba(56, 189, 248, 0.3);
padding: 2px 8px;
border-radius: 9999px;
text-decoration: none;
transition: all 0.18s ease;
cursor: pointer;
}
.ct-civitai-pill svg {
flex-shrink: 0;
}
.ct-civitai-pill span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ct-civitai-pill:hover {
background: rgba(56, 189, 248, 0.22);
border-color: rgba(56, 189, 248, 0.5);
color: #7dd3fc;
transform: translateY(-1px);
}
.ct-top-actions {
display: flex;
align-items: center;
gap: 6px;
}
/* VisionOS-Style Floating Bottom Control Dock */
.ct-dock {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 5px;
padding: 6px 10px;
background: var(--ct-surface);
border: 1px solid var(--ct-border);
border-radius: 9999px;
box-shadow: 0 25px 45px -10px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.12);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.ct-dock-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 9999px;
background: transparent;
border: none;
color: #cbd5e1;
cursor: pointer;
position: relative;
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
outline: none;
}
.ct-dock-btn:hover {
background: rgba(255, 255, 255, 0.09);
color: #ffffff;
transform: scale(1.04);
}
.ct-dock-btn:active {
transform: scale(0.92);
}
.ct-dock-btn.ct-active {
color: var(--ct-accent);
background: rgba(56, 189, 248, 0.15);
border: 1px solid rgba(56, 189, 248, 0.3);
}
.ct-dock-btn.ct-hero-play {
background: #f8fafc;
color: #06080d;
box-shadow: 0 4px 14px rgba(255, 255, 255, 0.2);
}
.ct-dock-btn.ct-hero-play:hover {
background: #ffffff;
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(255, 255, 255, 0.3);
}
.ct-dock-divider {
width: 1px;
height: 22px;
background: var(--ct-border);
margin: 0 4px;
}
/* Speed Scrubber Selector */
.ct-speed-group {
display: flex;
align-items: center;
gap: 2px;
background: rgba(0, 0, 0, 0.3);
border-radius: 9999px;
padding: 3px;
}
.ct-speed-item {
background: transparent;
border: none;
color: var(--ct-text-dim);
font-size: 11px;
font-weight: 600;
padding: 4px 9px;
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s ease;
}
.ct-speed-item:hover {
color: #f1f5f9;
}
.ct-speed-item.ct-selected {
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
}
/* Collapsible Bottom Filmstrip Carousel */
.ct-filmstrip {
position: absolute;
bottom: 84px;
left: 50%;
transform: translateX(-50%) translateY(20px);
z-index: 9;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
max-width: 85vw;
overflow-x: auto;
scrollbar-width: none;
background: var(--ct-surface);
border: 1px solid var(--ct-border);
border-radius: 14px;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow: 0 20px 40px -10px rgba(0, 0, 0, 0.7);
opacity: 0;
pointer-events: none;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.ct-filmstrip::-webkit-scrollbar { display: none; }
.ct-filmstrip.ct-open {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%) translateY(0);
}
.ct-thumb-card {
position: relative;
width: 58px;
height: 58px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
border: 1px solid transparent;
cursor: pointer;
opacity: 0.6;
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.ct-thumb-card img, .ct-thumb-card video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ct-thumb-card:hover {
opacity: 0.9;
transform: scale(1.05);
}
.ct-thumb-card.ct-active {
opacity: 1;
border-color: var(--ct-accent);
box-shadow: 0 0 12px var(--ct-accent-glow);
transform: scale(1.1);
}
/* Generation Specs & Inspector Side Drawer (Apple/Xcode Quality) */
.ct-inspector {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 400px;
max-width: 90vw;
background: rgba(9, 13, 22, 0.94);
border-left: 1px solid var(--ct-border);
z-index: 25;
display: flex;
flex-direction: column;
box-shadow: -25px 0 60px rgba(0, 0, 0, 0.8);
backdrop-filter: blur(32px) saturate(180%);
-webkit-backdrop-filter: blur(32px) saturate(180%);
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.ct-inspector.ct-open {
transform: translateX(0);
}
.ct-inspector-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--ct-border);
}
.ct-inspector-title {
font-size: 14px;
font-weight: 700;
letter-spacing: -0.01em;
color: #ffffff;
display: flex;
align-items: center;
gap: 9px;
}
.ct-inspector-title svg {
color: var(--ct-accent);
}
.ct-inspector-tabs {
display: flex;
padding: 0 22px;
border-bottom: 1px solid var(--ct-border);
gap: 16px;
}
.ct-tab-btn {
padding: 10px 0;
background: transparent;
border: none;
color: var(--ct-text-dim);
font-size: 12px;
font-weight: 600;
cursor: pointer;
position: relative;
transition: color 0.15s ease;
}
.ct-tab-btn:hover {
color: #cbd5e1;
}
.ct-tab-btn.ct-active {
color: #ffffff;
}
.ct-tab-btn.ct-active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--ct-accent);
box-shadow: 0 0 8px var(--ct-accent-glow);
}
.ct-inspector-body {
flex: 1;
overflow-y: auto;
padding: 22px;
display: flex;
flex-direction: column;
gap: 18px;
}
.ct-spec-card {
background: var(--ct-surface-card);
border: 1px solid var(--ct-border);
border-radius: 10px;
padding: 14px;
position: relative;
}
.ct-spec-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ct-text-dim);
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.ct-spec-copy-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--ct-border);
border-radius: 6px;
color: var(--ct-text-muted);
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.ct-spec-copy-btn:hover {
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
}
.ct-prompt-box {
font-family: var(--ct-mono);
font-size: 12px;
line-height: 1.6;
color: #cbd5e1;
max-height: 150px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
-webkit-user-select: text;
}
.ct-stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 10px;
}
.ct-stat-cell {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--ct-border);
border-radius: 6px;
padding: 8px 10px;
}
.ct-stat-header {
font-size: 10px;
font-weight: 600;
color: var(--ct-text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ct-stat-data {
font-size: 12px;
font-weight: 600;
color: #f8fafc;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ct-lora-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.ct-lora-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--ct-border);
border-radius: 9999px;
font-size: 11px;
color: #e2e8f0;
max-width: 100%;
}
.ct-lora-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.ct-lora-weight {
font-family: var(--ct-mono);
font-size: 10px;
color: #38bdf8;
background: rgba(56, 189, 248, 0.12);
padding: 1px 5px;
border-radius: 4px;
font-weight: 600;
}
/* Keyboard Shortcuts Modal / Drawer */
.ct-shortcut-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ct-key-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 0;
font-size: 12px;
}
.ct-key-label {
color: #cbd5e1;
}
.ct-kbd {
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 5px;
padding: 2px 7px;
font-family: var(--ct-mono);
font-size: 11px;
color: #ffffff;
box-shadow: 0 1px 2px rgba(0,0,0,0.4);
}
/* Auto-Hide HUD State */
.ct-hud-hidden .ct-top-header {
opacity: 0;
transform: translateY(-10px);
pointer-events: none;
}
.ct-hud-hidden .ct-dock {
opacity: 0;
transform: translate(-50%, 12px);
pointer-events: none;
}
.ct-hud-hidden .ct-edge-arrow {
opacity: 0;
pointer-events: none;
}
.ct-hud-hidden .ct-filmstrip {
opacity: 0;
pointer-events: none;
}
/* Prevent mobile bottom navigation collision (e.g. Civitai profile tab) */
@media (max-width: 768px) {
#ct-launcher-btn {
bottom: calc(env(safe-area-inset-bottom, 0px) + 84px);
right: 16px;
padding: 8px 14px;
font-size: 12px;
}
}
/* Mobile / Tablet Responsive Layouts */
@media (max-width: 640px) {
.ct-top-bar {
padding: calc(8px + env(safe-area-inset-top, 0px)) calc(12px + env(safe-area-inset-right, 0px)) 8px calc(12px + env(safe-area-inset-left, 0px));
gap: 8px;
}
.ct-brand-meta {
gap: 6px;
font-size: 12px;
min-width: 0;
flex-shrink: 1;
white-space: nowrap;
}
.ct-brand-name,
.ct-quality-tag {
display: none;
}
.ct-counter-pill {
font-size: 11px;
padding: 2px 7px;
white-space: nowrap;
flex-shrink: 0;
}
.ct-civitai-pill {
max-width: 110px;
min-width: 0;
flex-shrink: 1;
padding: 2px 7px;
font-size: 11px;
gap: 4px;
}
.ct-top-actions {
gap: 5px;
flex-shrink: 0;
}
.ct-top-actions #ct-btn-fs,
.ct-top-actions #ct-btn-ext {
display: none !important;
}
.ct-top-actions .ct-dock-btn {
width: 32px;
height: 32px;
}
#ct-btn-close {
width: 34px;
height: 34px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.16);
color: #fff;
}
.ct-dock {
bottom: calc(14px + env(safe-area-inset-bottom, 0px));
padding: 4px 6px;
gap: 3px;
max-width: 94vw;
}
.ct-dock-btn {
width: 34px;
height: 34px;
}
.ct-edge-arrow {
display: none;
}
.ct-speed-group {
display: none;
}
.ct-inspector {
width: 100vw;
max-width: 100vw;
}
.ct-inspector-header {
padding: calc(14px + env(safe-area-inset-top, 0px)) 16px 14px 16px;
}
.ct-inspector-body {
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
}
.ct-filmstrip {
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
max-width: 95vw;
}
.ct-zoom-hud {
top: calc(56px + env(safe-area-inset-top, 0px));
right: calc(14px + env(safe-area-inset-right, 0px));
}
}
@media (max-width: 480px) {
.ct-top-actions #ct-btn-filmstrip {
display: none !important;
}
.ct-civitai-pill {
max-width: 120px;
}
}
/* Floating Glass Toast for Page Actions */
#ct-page-toast {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
color: #f8fafc;
padding: 10px 18px;
border-radius: 9999px;
font-size: 13px;
font-weight: 600;
letter-spacing: -0.01em;
z-index: 2147483647;
opacity: 0;
pointer-events: none;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
#ct-page-toast.ct-visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
`;
// --- Clean-Room Helpers ---
function upgradeCivitaiUrl(url, mode = 'hd') {
if (!url) return '';
if (!url.includes('civitai.com')) return url;
try {
const uuidRegex = /\/xG1nkqKTMzGDvpLrqFT7WA\/([a-f0-9-]{36})\/([^/]+)\/(.+)$/i;
const match = url.match(uuidRegex);
if (!match) {
if (mode === 'raw') {
return url.replace(/\/width=\d+[^/]*\//g, '/original=true/').replace(/\/transcode=[^/]*\//g, '/original=true/');
} else {
return url.includes('/width=') ? url.replace(/\/width=\d+/g, '/width=2048') : url;
}
}
const [_, uuid, transformSeg, rest] = match;
if (mode === 'raw') {
return url.replace(uuidRegex, `/xG1nkqKTMzGDvpLrqFT7WA/${uuid}/original=true/${rest}`);
} else {
if (transformSeg === 'original=true') return url;
let newTransform = transformSeg;
if (/width=\d+/i.test(newTransform)) {
newTransform = newTransform.replace(/width=\d+/i, 'width=2048');
} else {
newTransform = `width=2048,${newTransform}`;
}
return url.replace(uuidRegex, `/xG1nkqKTMzGDvpLrqFT7WA/${uuid}/${newTransform}/${rest}`);
}
} catch (_) {
return url;
}
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function sanitizeMediaUrl(url) {
const raw = String(url || '').trim();
if (!raw) return '';
if (/^(https?:)?\/\//i.test(raw) || raw.startsWith('/') || raw.startsWith('blob:') || raw.startsWith('file:')) return raw;
return '';
}
function cssImageUrl(url) {
const safe = sanitizeMediaUrl(url);
if (!safe) return 'none';
return `url("${safe.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '')}")`;
}
// --- Civitai Theater Class ---
class CivitaiTheater {
constructor() {
window.__CIVITAI_THEATER_INSTANCE__ = this;
this.items = [];
this.seenIds = new Set();
this.currentIndex = 0;
this.isPlaying = false;
this.intervalMs = 5000;
this.progressTimer = null;
this.elapsedMs = 0;
this.isShuffled = false;
this.isMuted = true;
this.qualityMode = 'hd';
this.isInspectorOpen = false;
this.isFilmstripOpen = false;
this.isZenMode = false;
this.idleTimer = null;
this.zoom = 1.0;
this.panX = 0;
this.panY = 0;
this.isDragging = false;
this.dragStartX = 0;
this.dragStartY = 0;
this.imageCache = new Map();
this.cacheLimit = 24;
this.pendingPreloads = new Set();
this.slideRequestId = 0;
this.loaderTimer = null;
this.resizeRaf = null;
this.injectStyles();
this.buildDOM();
this.bindEvents();
this.startObserver();
this.scanPageMedia();
this.setupExtensionBridge();
this.checkAutoOpen();
}
checkAutoOpen() {
try {
const urlParams = new URLSearchParams(window.location.search);
const theaterParam = urlParams.get('theater') || urlParams.get('url') || urlParams.get('civitai');
const slugParam = urlParams.get('slug');
if (slugParam) {
setTimeout(() => this.openBySlug(slugParam), 200);
return;
}
if (theaterParam) {
if (theaterParam.startsWith('http') || theaterParam.includes('civitai') || /^\d+$/.test(theaterParam)) {
setTimeout(() => this.loadCivitaiUrl(theaterParam), 150);
return;
}
}
if (theaterParam === '1' || window.location.hash === '#theater') {
const attempt = (retries = 12) => {
this.scanPageMedia();
if (this.items.length > 0) {
this.open(0);
} else if (retries > 0) {
setTimeout(() => attempt(retries - 1), 250);
}
};
setTimeout(() => attempt(), 150);
}
} catch (_) {}
}
setupExtensionBridge() {
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
chrome.runtime.onMessage.addListener((req, sender, sendRes) => {
if (req.action === 'LAUNCH_THEATER') {
this.open();
sendRes({ status: 'ok', count: this.items.length });
} else if (req.action === 'GET_COUNT') {
sendRes({ count: this.items.length });
} else if (req.action === 'LOAD_CIVITAI_URL') {
this.loadCivitaiUrl(req.url).then(ok => sendRes({ status: ok ? 'ok' : 'error' }));
return true;
} else if (req.action === 'GET_SHARE_URL') {
const url = this.getDecomfyShareUrl();
sendRes({ status: 'ok', url });
}
});
}
}
injectStyles() {
const styleEl = document.createElement('style');
styleEl.id = 'ct-theater-styles';
styleEl.textContent = STYLES;
document.head.appendChild(styleEl);
}
buildDOM() {
// 1. Floating Launcher Button (Civitai only; suppressed on Decomfy which uses native UI)
const isDecomfyHost = typeof location !== 'undefined' && location.hostname.includes('decomfy.com');
this.launcherBtn = document.createElement('button');
this.launcherBtn.id = 'ct-launcher-btn';
this.launcherBtn.innerHTML = `
${ICONS.film}
Theater
`;
this.launcherBtn.title = 'Launch Fullscreen Slideshow (Alt+S)';
if (isDecomfyHost) {
this.launcherBtn.style.display = 'none';
}
document.body.appendChild(this.launcherBtn);
// 2. Main Theater Overlay
this.overlay = document.createElement('div');
this.overlay.id = 'ct-overlay';
this.overlay.className = 'ct-overlay';
this.overlay.setAttribute('aria-label', 'Civitai Theater');
this.overlay.setAttribute('aria-hidden', 'true');
this.overlay.innerHTML = `
FIT
${ICONS.chevronLeft}Before
${ICONS.loader}
After${ICONS.chevronRight}
`;
document.body.appendChild(this.overlay);
// Cache DOM references
this.ambientGlow = this.overlay.querySelector('#ct-ambient-glow');
this.mediaCanvas = this.overlay.querySelector('#ct-media-canvas');
this.counterEl = this.overlay.querySelector('#ct-counter');
this.progressFill = this.overlay.querySelector('#ct-progress-fill');
this.zoomHud = this.overlay.querySelector('#ct-zoom-hud');
this.dock = this.overlay.querySelector('#ct-dock');
this.filmstrip = this.overlay.querySelector('#ct-filmstrip');
this.inspector = this.overlay.querySelector('#ct-inspector');
this.inspectorBody = this.overlay.querySelector('#ct-inspector-body');
this.playBtn = this.overlay.querySelector('#ct-dock-play');
this.shuffleBtn = this.overlay.querySelector('#ct-dock-shuffle');
this.muteBtn = this.overlay.querySelector('#ct-dock-mute');
this.filmstripBtn = this.overlay.querySelector('#ct-btn-filmstrip');
this.inspectBtn = this.overlay.querySelector('#ct-btn-inspect');
this.headerCivitaiPill = this.overlay.querySelector('#ct-header-civitai-pill');
this.headerCivitaiText = this.overlay.querySelector('#ct-header-civitai-text');
}
bindEvents() {
// Launcher Click
this.launcherBtn.addEventListener('click', () => this.open());
// Navigation & Playback Controls
this.overlay.querySelector('#ct-dock-prev').addEventListener('click', (e) => { e.stopPropagation(); this.prev(); });
this.overlay.querySelector('#ct-dock-next').addEventListener('click', (e) => { e.stopPropagation(); this.next(); });
this.overlay.querySelector('#ct-edge-prev').addEventListener('click', (e) => { e.stopPropagation(); this.prev(); });
this.overlay.querySelector('#ct-edge-next').addEventListener('click', (e) => { e.stopPropagation(); this.next(); });
this.playBtn.addEventListener('click', (e) => { e.stopPropagation(); this.togglePlay(); });
// Click zones on stage
this.overlay.querySelector('#ct-hit-prev').addEventListener('click', () => this.prev());
this.overlay.querySelector('#ct-hit-next').addEventListener('click', () => this.next());
// Flank clicks (Before & After)
const flankPrev = this.overlay.querySelector('#ct-flank-prev');
const flankNext = this.overlay.querySelector('#ct-flank-next');
if (flankPrev) {
flankPrev.addEventListener('click', (e) => {
e.stopPropagation();
this.prev();
});
}
if (flankNext) {
flankNext.addEventListener('click', (e) => {
e.stopPropagation();
this.next();
});
}
// Speed selection
this.overlay.querySelectorAll('.ct-speed-item').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const speed = parseInt(btn.dataset.speed, 10);
this.setSpeed(speed);
this.overlay.querySelectorAll('.ct-speed-item').forEach(b => b.classList.remove('ct-selected'));
btn.classList.add('ct-selected');
});
});
// Shuffle toggle
this.shuffleBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.isShuffled = !this.isShuffled;
this.shuffleBtn.classList.toggle('ct-active', this.isShuffled);
});
// Audio Mute toggle
this.muteBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleMute();
});
// Zen Mode toggle
this.overlay.querySelector('#ct-dock-zen').addEventListener('click', (e) => {
e.stopPropagation();
this.toggleZenMode();
});
// Actions in top bar
this.filmstripBtn.addEventListener('click', () => this.toggleFilmstrip());
this.inspectBtn.addEventListener('click', () => this.toggleInspector());
this.overlay.querySelector('#ct-btn-close').addEventListener('click', () => this.close());
this.overlay.querySelector('#ct-btn-close-inspector').addEventListener('click', () => this.toggleInspector(false));
this.overlay.querySelector('#ct-btn-fs').addEventListener('click', () => this.toggleFullscreen());
this.overlay.querySelector('#ct-btn-download').addEventListener('click', () => this.downloadCurrent());
const shareBtn = this.overlay.querySelector('#ct-btn-share-decomfy');
if (shareBtn) shareBtn.addEventListener('click', () => this.shareViaDecomfy());
this.overlay.querySelector('#ct-btn-ext').addEventListener('click', () => this.openExternal());
// Inspector Tabs
this.overlay.querySelector('#ct-tab-specs').addEventListener('click', () => this.switchInspectorTab('specs'));
this.overlay.querySelector('#ct-tab-shortcuts').addEventListener('click', () => this.switchInspectorTab('shortcuts'));
// Interactive Pan & Zoom
const stage = this.overlay.querySelector('#ct-stage');
stage.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
stage.addEventListener('mousedown', (e) => this.handleMouseDown(e));
stage.addEventListener('dblclick', () => this.handleDoubleClick());
// Mouse Idle Auto-Hide
this.overlay.addEventListener('mousemove', () => this.handleUserActivity());
this.overlay.addEventListener('click', () => this.handleUserActivity());
// Global Keyboard Controls
window.addEventListener('keydown', (e) => this.handleKeyDown(e));
// Window resize listener to recompute Before/After flanks & 100% media sizing
window.addEventListener('resize', () => {
if (!this.overlay.classList.contains('ct-active')) return;
cancelAnimationFrame(this.resizeRaf);
this.resizeRaf = requestAnimationFrame(() => this.refreshFlanks());
});
// Touch Gestures for Mobile & Tablet (Pinch-to-zoom + Pan + Swipe navigation + Tap to toggle HUD)
let touchStartX = 0;
let touchStartY = 0;
let initialDist = 0;
let initialZoom = 1.0;
let isPinching = false;
let lastTapTime = 0;
let touchStartTime = 0;
let isTouchDragging = false;
let touchDragStartX = 0;
let touchDragStartY = 0;
this.overlay.addEventListener('touchstart', (e) => {
// Ignore touches inside inspector, dock, filmstrip, top header
if (e.target.closest('#ct-inspector') || e.target.closest('#ct-dock') || e.target.closest('#ct-filmstrip') || e.target.closest('.ct-top-header')) {
return;
}
if (e.touches.length === 2) {
isPinching = true;
isTouchDragging = false;
initialDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
initialZoom = this.zoom;
} else if (e.touches.length === 1) {
isPinching = false;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchStartTime = Date.now();
if (this.zoom > 1.0) {
isTouchDragging = true;
touchDragStartX = e.touches[0].clientX - this.panX;
touchDragStartY = e.touches[0].clientY - this.panY;
} else {
isTouchDragging = false;
}
}
}, { passive: false });
this.overlay.addEventListener('touchmove', (e) => {
if (e.target.closest('#ct-inspector') || e.target.closest('#ct-dock') || e.target.closest('#ct-filmstrip') || e.target.closest('.ct-top-header')) {
return;
}
if (isPinching && e.touches.length === 2) {
e.preventDefault();
const currentDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
if (initialDist > 0) {
const scale = currentDist / initialDist;
this.zoom = Math.min(Math.max(1.0, initialZoom * scale), 4.0);
if (this.zoom === 1.0) {
this.panX = 0;
this.panY = 0;
}
this.clampPan();
this.applyTransform();
this.showZoomHud();
}
} else if (isTouchDragging && e.touches.length === 1 && this.zoom > 1.0) {
e.preventDefault();
this.panX = e.touches[0].clientX - touchDragStartX;
this.panY = e.touches[0].clientY - touchDragStartY;
this.clampPan();
this.applyTransform();
}
}, { passive: false });
this.overlay.addEventListener('touchend', (e) => {
if (e.target.closest('#ct-inspector') || e.target.closest('#ct-dock') || e.target.closest('#ct-filmstrip') || e.target.closest('.ct-top-header')) {
return;
}
if (isPinching) {
if (e.touches.length < 2) isPinching = false;
return;
}
if (isTouchDragging) {
isTouchDragging = false;
return;
}
if (!e.changedTouches || e.changedTouches.length === 0) return;
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const diffX = touchEndX - touchStartX;
const diffY = touchEndY - touchStartY;
const distance = Math.hypot(diffX, diffY);
const elapsed = Date.now() - touchStartTime;
// Double-tap or Single-tap detection on stage/image
const now = Date.now();
if (distance < 15 && elapsed < 280) {
if (now - lastTapTime < 320) {
// Double tap: toggle 2x zoom
this.handleDoubleClick();
lastTapTime = 0;
return;
}
lastTapTime = now;
// Single tap on stage: toggle HUD (Zen Mode)
setTimeout(() => {
if (lastTapTime === now) {
this.toggleZenMode();
}
}, 320);
return;
}
// Swipe Gestures (Only when not zoomed in)
if (this.zoom <= 1.0) {
if (Math.abs(diffX) > 40 && Math.abs(diffX) > Math.abs(diffY)) {
if (diffX > 0) this.prev();
else this.next();
} else if (diffY > 90 && Math.abs(diffY) > Math.abs(diffX)) {
// Swipe down to dismiss
this.close();
}
}
}, { passive: true });
}
handleUserActivity() {
if (this.isZenMode) return;
this.overlay.classList.remove('ct-hud-hidden');
clearTimeout(this.idleTimer);
if (this.isPlaying) {
this.idleTimer = setTimeout(() => {
if (!this.isInspectorOpen && !this.isFilmstripOpen) {
this.overlay.classList.add('ct-hud-hidden');
}
}, 2500);
}
}
handleWheel(e) {
if (e.target.closest('#ct-inspector') || e.target.closest('#ct-filmstrip')) return;
e.preventDefault();
const delta = e.deltaY * -0.002;
const prevZoom = this.zoom;
this.zoom = Math.min(Math.max(1.0, this.zoom + delta), 4.0);
if (this.zoom === 1.0) {
this.panX = 0;
this.panY = 0;
}
this.clampPan();
this.applyTransform();
this.showZoomHud();
}
handleMouseDown(e) {
if (!this.overlay.classList.contains('ct-active')) return;
if (this.zoom <= 1.0) return;
if (e.target.closest('#ct-inspector') || e.target.closest('#ct-dock') || e.target.closest('#ct-filmstrip')) return;
this.isDragging = true;
this.dragStartX = e.clientX - this.panX;
this.dragStartY = e.clientY - this.panY;
this.mediaCanvas.style.cursor = 'grabbing';
this._boundMouseMove = (ev) => this.handleMouseMove(ev);
this._boundMouseUp = () => this.handleMouseUp();
window.addEventListener('mousemove', this._boundMouseMove);
window.addEventListener('mouseup', this._boundMouseUp);
}
handleMouseMove(e) {
if (!this.isDragging) return;
this.panX = e.clientX - this.dragStartX;
this.panY = e.clientY - this.dragStartY;
this.clampPan();
this.applyTransform();
}
handleMouseUp() {
if (this.isDragging) {
this.isDragging = false;
this.mediaCanvas.style.cursor = this.zoom > 1.0 ? 'grab' : 'default';
}
if (this._boundMouseMove) {
window.removeEventListener('mousemove', this._boundMouseMove);
this._boundMouseMove = null;
}
if (this._boundMouseUp) {
window.removeEventListener('mouseup', this._boundMouseUp);
this._boundMouseUp = null;
}
}
handleDoubleClick() {
if (this.zoom > 1.0) {
this.zoom = 1.0;
this.panX = 0;
this.panY = 0;
} else {
this.zoom = 2.0;
}
this.clampPan();
this.applyTransform();
this.showZoomHud();
}
clampPan() {
if (this.zoom <= 1.0) {
this.panX = 0;
this.panY = 0;
return;
}
const stage = this.overlay.querySelector('#ct-stage');
if (!stage) return;
const maxX = Math.max(0, (this.mediaCanvas.offsetWidth * this.zoom - stage.clientWidth) / 2);
const maxY = Math.max(0, (this.mediaCanvas.offsetHeight * this.zoom - stage.clientHeight) / 2);
this.panX = Math.min(maxX, Math.max(-maxX, this.panX));
this.panY = Math.min(maxY, Math.max(-maxY, this.panY));
}
applyTransform() {
this.clampPan();
this.mediaCanvas.style.transform = `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`;
this.mediaCanvas.style.cursor = this.zoom > 1.0 ? (this.isDragging ? 'grabbing' : 'grab') : 'default';
const stage = this.overlay.querySelector('#ct-stage');
if (stage) {
stage.classList.toggle('ct-stage-zoomed', this.zoom > 1.0);
}
}
copyTextToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).then(() => true).catch(() => false);
}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return Promise.resolve(ok);
} catch (_) {
return Promise.resolve(false);
}
}
openRemix() {
const item = this.items[this.currentIndex];
if (item && item.imageId) {
window.open('https://civitai.com/generate?remixImageId=' + encodeURIComponent(item.imageId), '_blank', 'noopener,noreferrer');
}
}
copyCivitaiLink() {
const item = this.items[this.currentIndex];
if (!item) return;
const link = item.civitaiUrl || (item.imageId ? `https://civitai.com/images/${encodeURIComponent(item.imageId)}` : '');
if (link) {
this.copyTextToClipboard(link).then((ok) => {
if (ok) this.showZoomHud('LINK COPIED');
});
}
}
getDecomfyShareUrl(item = null) {
const currentItem = item || (this.items && this.items[this.currentIndex]);
if (currentItem && currentItem.decomfySlug) {
return `https://decomfy.com/theater?slug=${encodeURIComponent(currentItem.decomfySlug)}`;
}
let civUrl = '';
if (currentItem && currentItem.civitaiUrl) {
civUrl = currentItem.civitaiUrl;
} else if (currentItem && currentItem.imageId) {
civUrl = `https://civitai.com/images/${encodeURIComponent(currentItem.imageId)}`;
} else if (currentItem && currentItem.postId) {
civUrl = `https://civitai.com/posts/${encodeURIComponent(currentItem.postId)}`;
} else if (typeof window !== 'undefined' && window.location) {
const href = window.location.href;
if (href.includes('civitai.com') || href.includes('civitai.red')) {
civUrl = href;
}
}
if (civUrl) {
return `https://decomfy.com/theater?url=${encodeURIComponent(civUrl)}`;
}
return 'https://decomfy.com/theater';
}
shareViaDecomfy() {
const item = this.items[this.currentIndex];
const shareUrl = this.getDecomfyShareUrl(item);
this.copyTextToClipboard(shareUrl).then((ok) => {
if (ok) {
this.showZoomHud('DECOMFY LINK COPIED');
this.showPageNotice('Decomfy slideshow link copied to clipboard!');
}
});
}
copyCurrentPrompt() {
const item = this.items[this.currentIndex];
if (!item) return;
const metaPrompt = item.meta && item.meta.prompt ? String(item.meta.prompt).trim() : '';
const domText = this.inspectorBody && this.inspectorBody.querySelector('#ct-prompt-text')
? this.inspectorBody.querySelector('#ct-prompt-text').textContent.trim()
: '';
const domPrompt = domText && !/^(Loading telemetry|No positive prompt)/i.test(domText) ? domText : '';
const prompt = metaPrompt || domPrompt;
if (prompt) {
this.copyTextToClipboard(prompt).then((ok) => {
if (ok) this.showZoomHud('PROMPT COPIED');
});
}
}
showZoomHud(customText = null) {
const text = customText || (this.zoom === 1.0 ? 'FIT' : `${Math.round(this.zoom * 100)}%`);
this.zoomHud.textContent = text;
this.zoomHud.classList.add('ct-visible');
clearTimeout(this.zoomHudTimer);
this.zoomHudTimer = setTimeout(() => {
this.zoomHud.classList.remove('ct-visible');
}, 1500);
}
handleKeyDown(e) {
if (e.altKey && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
this.open();
return;
}
if (!this.overlay.classList.contains('ct-active')) return;
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
switch (e.key) {
case 'Escape':
e.preventDefault();
if (this.isInspectorOpen) this.toggleInspector(false);
else if (this.isFilmstripOpen) this.toggleFilmstrip(false);
else if (this.zoom > 1.0) { this.zoom = 1.0; this.applyTransform(); this.showZoomHud(); }
else this.close();
break;
case ' ':
e.preventDefault();
this.togglePlay();
break;
case 'ArrowRight':
case 'j':
case 'J':
case 'Enter':
e.preventDefault();
if (this.zoom > 1.0 && e.key === 'ArrowRight') {
this.panX -= 50;
this.applyTransform();
} else {
this.next();
}
break;
case 'ArrowLeft':
case 'k':
case 'K':
e.preventDefault();
if (this.zoom > 1.0 && e.key === 'ArrowLeft') {
this.panX += 50;
this.applyTransform();
} else {
this.prev();
}
break;
case 'ArrowUp':
if (this.zoom > 1.0) {
e.preventDefault();
this.panY += 50;
this.applyTransform();
}
break;
case 'ArrowDown':
if (this.zoom > 1.0) {
e.preventDefault();
this.panY -= 50;
this.applyTransform();
}
break;
case '+':
case '=':
e.preventDefault();
this.zoom = Math.min(4.0, Number((this.zoom + 0.25).toFixed(2)));
this.applyTransform();
this.showZoomHud();
break;
case '-':
case '_':
e.preventDefault();
this.zoom = Math.max(1.0, Number((this.zoom - 0.25).toFixed(2)));
if (this.zoom === 1.0) { this.panX = 0; this.panY = 0; }
this.applyTransform();
this.showZoomHud();
break;
case '0':
e.preventDefault();
this.zoom = 1.0;
this.panX = 0;
this.panY = 0;
this.applyTransform();
this.showZoomHud();
break;
case 'f':
case 'F':
e.preventDefault();
this.toggleFullscreen();
break;
case 's':
case 'S':
e.preventDefault();
this.isShuffled = !this.isShuffled;
this.shuffleBtn.classList.toggle('ct-active', this.isShuffled);
break;
case 'i':
case 'I':
e.preventDefault();
this.toggleInspector();
break;
case 't':
case 'T':
e.preventDefault();
this.toggleFilmstrip();
break;
case 'h':
case 'H':
e.preventDefault();
this.toggleZenMode();
break;
case 'm':
case 'M':
e.preventDefault();
this.toggleMute();
break;
case 'z':
case 'Z':
e.preventDefault();
this.handleDoubleClick();
break;
case 'd':
case 'D':
e.preventDefault();
this.downloadCurrent();
break;
case 'o':
case 'O':
case 'v':
case 'V':
e.preventDefault();
this.openExternal();
break;
case 'r':
case 'R':
e.preventDefault();
this.openRemix();
break;
case 'c':
case 'C':
e.preventDefault();
if (e.shiftKey || e.altKey) {
this.shareViaDecomfy();
} else {
this.copyCivitaiLink();
}
break;
case 'p':
case 'P':
e.preventDefault();
this.copyCurrentPrompt();
break;
case '1': this.setSpeed(3000); break;
case '2': this.setSpeed(5000); break;
case '3': this.setSpeed(8000); break;
case '4': this.setSpeed(12000); break;
case '5': this.setSpeed(0); break;
}
}
startObserver() {
let debounceTimeout = null;
const observer = new MutationObserver((mutations) => {
const hasExternalMutation = mutations.some(m => {
const target = m.target;
if (!target || !target.closest) return false;
if (target.closest('#ct-overlay') || target.closest('#ct-launcher-btn')) {
return false;
}
if (target.closest('.mantine-Menu-dropdown, [data-menu-dropdown], [role="menu"], [data-popover], .mantine-Popover-dropdown, .mantine-Tooltip-tooltip')) {
return false;
}
return true;
});
if (!hasExternalMutation) return;
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
this.scanPageMedia();
}, 400);
});
observer.observe(document.body, { childList: true, subtree: true });
}
scanPageMedia() {
// Strictly target artwork elements on Civitai feeds, carousels, cards, and detail pages
const candidates = document.querySelectorAll(`
[data-image-id] img,
[data-image-id] video,
[data-image-id],
a[href*="/images/"] img,
a[href*="/images/"] video,
a[href*="/posts/"] img,
a[href*="/posts/"] video,
.mantine-Card-root img,
.mantine-Card-root video,
.mantine-Carousel-slide img,
.mantine-Carousel-slide video,
[class*="Carousel-slide"] img,
[class*="Carousel-slide"] video,
[class*="carousel"] img,
[class*="carousel"] video,
[class*="ImageGuard"] img,
[class*="ImageGuard"] video,
[class*="MasonryCard"] img,
[class*="MasonryCard"] video,
[class*="FeedCard"] img,
[class*="FeedCard"] video,
[class*="ImageCard"] img,
[class*="ImageCard"] video,
[class*="ImageViewer"] img,
[class*="ImageViewer"] video,
main img[src*="civitai.com"],
main video,
img[src*="image.civitai.com"],
video[src*="image.civitai.com"],
img[src*="xG1nkqKTMzGDvpLrqFT7WA"],
video[src*="xG1nkqKTMzGDvpLrqFT7WA"],
.lab-card img,
.lab-card video,
.lab-card-media img,
.lab-card-media video,
[data-slug] img,
[data-slug] video
`);
// Junk keyword pattern for icons, badges, avatars, cosmetics, buzz, and UI decorations
const JUNK_URL_REGEX = /\/(badge|badges|cosmetic|cosmetics|avatar|avatars|icon|icons|logo|logos|buzz|coin|coins|reaction|reactions|emoji|emojis|sticker|stickers|award|awards|flair|tier)\b/i;
const JUNK_ATTR_REGEX = /\b(badge|badges|cosmetic|cosmetics|avatar|avatars|icon|icons|logo|logos|buzz|coin|coins|reaction|reactions|emoji|emojis|sticker|stickers|award|awards|medal|trophy|flair|tier|supporter|moderator|founder|rank|level)\b/i;
const JUNK_ANCESTOR_SELECTOR = `
[class*="badge" i],
[data-badge],
[class*="cosmetic" i],
[data-cosmetic],
[class*="avatar" i],
[data-avatar],
[class*="reaction" i],
[class*="buzz" i],
[class*="coin" i],
header,
nav,
aside,
footer,
[role="navigation"],
[class*="Header"],
[class*="Navbar"],
[class*="Sidebar"],
[class*="UserBadge"],
[class*="UserInfo"]
`;
// Civitai standard artwork storage UUID pattern
const CIVITAI_UUID_REGEX = /\/xG1nkqKTMzGDvpLrqFT7WA\/([a-f0-9-]{36})\//i;
candidates.forEach(el => {
// Skip elements inside theater overlay or launcher itself
if (el.closest('#ct-overlay, #ct-launcher-btn')) return;
// Skip any element nested inside junk containers (badges, avatars, reactions, cosmetics, navbars)
if (el.closest(JUNK_ANCESTOR_SELECTOR)) return;
let mediaUrl = '';
let isVideo = false;
let imageId = '';
let postId = '';
let username = '';
let poster = null;
if (el.tagName === 'VIDEO') {
isVideo = true;
mediaUrl = el.currentSrc || el.src;
poster = el.getAttribute('poster');
if (!mediaUrl) {
const srcEl = el.querySelector('source');
if (srcEl) mediaUrl = srcEl.src;
}
} else if (el.tagName === 'IMG') {
mediaUrl = el.currentSrc || el.src || (el.srcset ? el.srcset.split(',')[0].trim().split(' ')[0] : '');
} else if (el.tagName === 'A') {
const video = el.querySelector('video');
const img = el.querySelector('img');
if (video) {
isVideo = true;
mediaUrl = video.currentSrc || video.src;
poster = video.getAttribute('poster');
} else if (img) {
mediaUrl = img.currentSrc || img.src || (img.srcset ? img.srcset.split(',')[0].trim().split(' ')[0] : '');
}
} else if (el.hasAttribute('data-image-id')) {
const video = el.querySelector('video');
const img = el.querySelector('img');
if (video) {
isVideo = true;
mediaUrl = video.currentSrc || video.src;
poster = video.getAttribute('poster');
} else if (img) {
mediaUrl = img.currentSrc || img.src || (img.srcset ? img.srcset.split(',')[0].trim().split(' ')[0] : '');
}
}
if (!mediaUrl || mediaUrl.startsWith('data:')) return;
// 1. Strict URL check against junk badges/icons
if (JUNK_URL_REGEX.test(mediaUrl)) return;
if (poster && JUNK_URL_REGEX.test(poster)) return;
// 2. Strict attribute check (alt, title, aria-label, class with word boundary)
const alt = el.getAttribute('alt') || '';
const title = el.getAttribute('title') || '';
const ariaLabel = el.getAttribute('aria-label') || '';
const className = typeof el.className === 'string' ? el.className : '';
if (JUNK_ATTR_REGEX.test(alt) || JUNK_ATTR_REGEX.test(title) || JUNK_ATTR_REGEX.test(ariaLabel) || JUNK_ATTR_REGEX.test(className)) {
return;
}
// 3. Dimension guard: eliminate small badges, icons, stickers (real artworks are never tiny thumbnails)
if (el.tagName === 'IMG') {
if ((el.naturalWidth > 0 && el.naturalWidth < 180) || (el.naturalHeight > 0 && el.naturalHeight < 180)) return;
if (el.clientWidth > 0 && el.clientHeight > 0 && el.clientWidth < 160 && el.clientHeight < 160) return;
}
// 4. Resolve parent artwork card context
const parentCard = el.closest('[data-image-id], .mantine-Card-root, a[href*="/images/"], a[href*="/posts/"], [class*="ImageGuard"], [class*="MasonryCard"], [class*="FeedCard"], [class*="Carousel-slide"], [class*="ImageViewer"], .lab-card, [data-slug]');
let decomfyPayload = null;
let decomfySlug = null;
if (parentCard && (parentCard.classList.contains('lab-card') || parentCard.hasAttribute('data-slug'))) {
decomfySlug = parentCard.getAttribute('data-slug');
const payloadEl = parentCard.querySelector('.lab-card-payload');
if (payloadEl) {
try {
decomfyPayload = JSON.parse(payloadEl.textContent);
} catch (_) {}
}
}
// Find imageId / postId
const link = parentCard ? (parentCard.tagName === 'A' ? parentCard : parentCard.querySelector('a[href*="/images/"], a[href*="/posts/"]')) : (el.tagName === 'A' ? el : el.closest('a'));
const href = link ? (link.getAttribute('href') || '') : '';
const imgMatch = href.match(/\/images\/(\d+)/);
if (imgMatch) imageId = imgMatch[1];
if (!imageId && parentCard && parentCard.hasAttribute('data-image-id')) {
imageId = parentCard.getAttribute('data-image-id');
} else if (!imageId && el.hasAttribute('data-image-id')) {
imageId = el.getAttribute('data-image-id');
}
const postMatch = href.match(/\/posts\/(\d+)/);
if (postMatch) postId = postMatch[1];
if (!postId && parentCard && parentCard.hasAttribute('data-post-id')) {
postId = parentCard.getAttribute('data-post-id');
}
let civitaiUrl = '';
if (parentCard && parentCard.hasAttribute('data-civitai-url')) {
civitaiUrl = parentCard.getAttribute('data-civitai-url');
}
if (decomfyPayload) {
if (decomfyPayload.imageId) imageId = String(decomfyPayload.imageId);
else if (decomfyPayload.civitaiId) {
const cidMatch = String(decomfyPayload.civitaiId).match(/\d+/);
if (cidMatch) imageId = cidMatch[0];
}
if (decomfyPayload.postId) postId = String(decomfyPayload.postId);
if (decomfyPayload.civitaiUrl) civitaiUrl = decomfyPayload.civitaiUrl;
if (decomfyPayload.slug) decomfySlug = decomfyPayload.slug;
username = 'Decomfy Lab';
}
if (!civitaiUrl) {
if (imageId) civitaiUrl = `https://civitai.com/images/${encodeURIComponent(imageId)}`;
else if (postId) civitaiUrl = `https://civitai.com/posts/${encodeURIComponent(postId)}`;
}
// 5. Positive artwork verification: ONLY select actual artworks ("chỉ chọn các tác phẩm thôi")
// Must have an imageId, a postId, a verified Civitai artwork UUID, or be a verified Decomfy recipe card
const uuidMatch = mediaUrl.match(CIVITAI_UUID_REGEX);
const isDecomfy = Boolean(decomfyPayload || decomfySlug || location.hostname.includes('decomfy.com'));
const isVerifiedArtwork = Boolean(imageId || postId || uuidMatch || isDecomfy);
if (!isVerifiedArtwork) {
return; // Strictly reject anything that is not a verified artwork!
}
// Extract creator username if available
if (parentCard && !username) {
const userLink = parentCard.querySelector('a[href*="/user/"]');
if (userLink) {
const uMatch = userLink.getAttribute('href').match(/\/user\/([^/?#]+)/);
if (uMatch) username = uMatch[1];
}
}
const uniqueKey = imageId
? `img_${imageId}`
: (postId
? `post_${postId}_${mediaUrl.split('?')[0].split('/').slice(-1)[0]}`
: (uuidMatch
? `uuid_${uuidMatch[1].toLowerCase()}`
: (decomfySlug
? `decomfy_${decomfySlug}`
: `media_${mediaUrl.split('?')[0].split('/').slice(-1)[0]}`)));
if (this.seenIds.has(uniqueKey)) return;
this.seenIds.add(uniqueKey);
const hdUrl = sanitizeMediaUrl(upgradeCivitaiUrl(mediaUrl, 'hd'));
const rawUrl = sanitizeMediaUrl(upgradeCivitaiUrl(mediaUrl, 'raw'));
if (!hdUrl) {
this.seenIds.delete(uniqueKey);
return;
}
// Extract known aspect ratio and dimensions immediately from host page DOM if rendered
let isPortrait = null;
let natW = 0;
let natH = 0;
if (el.tagName === 'IMG' && el.naturalWidth > 0 && el.naturalHeight > 0) {
natW = el.naturalWidth;
natH = el.naturalHeight;
isPortrait = natH > natW;
} else if (el.tagName === 'VIDEO' && el.videoWidth > 0 && el.videoHeight > 0) {
natW = el.videoWidth;
natH = el.videoHeight;
isPortrait = natH > natW;
} else if (el.clientWidth > 0 && el.clientHeight > 0) {
isPortrait = el.clientHeight > el.clientWidth;
}
const metaObj = decomfyPayload ? {
prompt: decomfyPayload.prompt || decomfyPayload.title || '',
negativePrompt: decomfyPayload.negPrompt || '',
Model: decomfyPayload.model || '',
steps: decomfyPayload.steps || null,
cfgScale: decomfyPayload.cfg || null,
sampler: decomfyPayload.sampler ? `${decomfyPayload.sampler} ${decomfyPayload.scheduler || ''}`.trim() : null,
seed: decomfyPayload.seed != null ? decomfyPayload.seed : null
} : null;
this.items.push({
id: uniqueKey,
imageId: imageId,
postId: postId,
civitaiUrl: civitaiUrl,
username: username,
decomfySlug: decomfySlug,
type: isVideo ? 'video' : 'image',
url: hdUrl,
rawUrl: rawUrl,
thumbnail: sanitizeMediaUrl(isVideo ? (poster || '') : mediaUrl),
poster: sanitizeMediaUrl(poster),
width: natW,
height: natH,
isPortrait: isPortrait,
meta: metaObj || null,
baseModel: decomfyPayload ? (decomfyPayload.model || '') : '',
metaLoaded: Boolean(decomfyPayload)
});
});
if (this.overlay.classList.contains('ct-active')) {
this.updateCounter();
this.updateFilmstrip();
}
}
showPageNotice(message, duration = 3000) {
let toast = document.getElementById('ct-page-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'ct-page-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.add('ct-visible');
clearTimeout(this._pageToastTimer);
this._pageToastTimer = setTimeout(() => {
toast.classList.remove('ct-visible');
}, duration);
}
open(startIndex = null) {
if (this.overlay.classList.contains('ct-active')) return;
if (this.items.length === 0) {
this.scanPageMedia();
}
if (this.items.length === 0) {
this.showPageNotice('Civitai Theater: No artworks visible on screen yet. Scroll down to load images.');
return;
}
if (startIndex !== null && startIndex >= 0 && startIndex < this.items.length) {
this.currentIndex = startIndex;
}
document.documentElement.classList.add('ct-open-locked');
document.body.classList.add('ct-open-locked');
this.overlay.setAttribute('role', 'dialog');
this.overlay.setAttribute('aria-modal', 'true');
this.overlay.setAttribute('aria-hidden', 'false');
this.overlay.classList.add('ct-active');
this.renderCurrentSlide();
this.play();
}
openBySlug(slug) {
if (this.items.length === 0) this.scanPageMedia();
const idx = this.items.findIndex(it => it.decomfySlug === slug);
if (this.overlay.classList.contains('ct-active')) {
if (idx >= 0) {
this.currentIndex = idx;
this.renderCurrentSlide();
}
} else {
this.open(idx >= 0 ? idx : 0);
}
}
openByImageId(id) {
if (this.items.length === 0) this.scanPageMedia();
const cleanId = String(id).replace(/\D/g, '');
const idx = this.items.findIndex(it => it.imageId === cleanId);
if (this.overlay.classList.contains('ct-active')) {
if (idx >= 0) {
this.currentIndex = idx;
this.renderCurrentSlide();
}
} else {
this.open(idx >= 0 ? idx : 0);
}
}
async loadCivitaiUrl(rawInput, options = {}) {
if (!rawInput || typeof rawInput !== 'string') return false;
const trimmed = rawInput.trim();
if (!trimmed) return false;
this.showPageNotice('Loading Civitai slideshow...');
const loader = this.overlay.querySelector('#ct-stage-loader');
if (loader) loader.style.opacity = '1';
try {
let apiUrl = '';
let targetImageId = null;
let targetPostId = null;
// 1. Detect Image: /images/12345 or plain number
const imgMatch = trimmed.match(/\/images\/(\d+)/i) || (trimmed.match(/^\d+$/) ? [null, trimmed] : null);
if (imgMatch) {
targetImageId = imgMatch[1];
apiUrl = `https://civitai.com/api/v1/images?imageId=${encodeURIComponent(targetImageId)}&nsfw=X`;
}
// 2. Detect Post: /posts/12345
const postMatch = trimmed.match(/\/posts\/(\d+)/i);
if (postMatch) {
targetPostId = postMatch[1];
apiUrl = `https://civitai.com/api/v1/images?postId=${encodeURIComponent(targetPostId)}&nsfw=X`;
}
// 3. Detect Model: /models/12345 or modelVersionId
const modelMatch = trimmed.match(/\/models\/(\d+)/i);
const versionMatch = trimmed.match(/[?&]modelVersionId=(\d+)/i);
if (versionMatch) {
apiUrl = `https://civitai.com/api/v1/images?modelVersionId=${encodeURIComponent(versionMatch[1])}&limit=50&nsfw=X`;
} else if (modelMatch && !apiUrl) {
try {
const mRes = await fetch(`https://civitai.com/api/v1/models/${encodeURIComponent(modelMatch[1])}`);
if (mRes.ok) {
const mData = await mRes.json();
const vId = mData.modelVersions && mData.modelVersions[0] && mData.modelVersions[0].id;
if (vId) {
apiUrl = `https://civitai.com/api/v1/images?modelVersionId=${encodeURIComponent(vId)}&limit=50&nsfw=X`;
}
}
} catch (_) {}
}
// 4. Detect User: /user/username
const userMatch = trimmed.match(/\/user\/([a-zA-Z0-9_\-]+)/i);
if (userMatch && !apiUrl) {
apiUrl = `https://civitai.com/api/v1/images?username=${encodeURIComponent(userMatch[1])}&limit=50&nsfw=X`;
}
// 5. Default fallback to general images feed
if (!apiUrl) {
apiUrl = 'https://civitai.com/api/v1/images?limit=50&nsfw=X';
}
const res = await fetch(apiUrl);
if (!res.ok) throw new Error(`Civitai API status ${res.status}`);
const data = await res.json();
let rawItems = Array.isArray(data.items) ? data.items : [];
// If single image and it belongs to a post album, fetch entire post for full slideshow
if (targetImageId && rawItems.length > 0 && rawItems[0].postId) {
try {
const albumRes = await fetch(`https://civitai.com/api/v1/images?postId=${encodeURIComponent(rawItems[0].postId)}&nsfw=X`);
if (albumRes.ok) {
const albumData = await albumRes.json();
if (Array.isArray(albumData.items) && albumData.items.length > 1) {
rawItems = albumData.items;
}
}
} catch (_) {}
}
if (rawItems.length === 0) {
this.showPageNotice('No artworks found for this Civitai link.');
return false;
}
const parsedItems = rawItems.map((img) => {
const natW = img.width || 0;
const natH = img.height || 0;
const isPortrait = natH > natW;
const isVideo = img.type === 'video' || Boolean(img.url && /\.(mp4|webm)$/i.test(img.url));
const hdUrl = sanitizeMediaUrl(upgradeCivitaiUrl(img.url, 'hd'));
const rawUrl = sanitizeMediaUrl(img.url);
const thumb = sanitizeMediaUrl(upgradeCivitaiUrl(img.url, 'thumb'));
const metaObj = img.meta && typeof img.meta === 'object' ? img.meta : null;
return {
id: `img_${img.id}`,
imageId: String(img.id),
postId: img.postId ? String(img.postId) : '',
civitaiUrl: `https://civitai.com/images/${encodeURIComponent(img.id)}`,
username: img.username || 'Civitai Creator',
decomfySlug: null,
type: isVideo ? 'video' : 'image',
url: hdUrl,
rawUrl: rawUrl,
thumbnail: thumb,
poster: thumb,
width: natW,
height: natH,
isPortrait: isPortrait,
meta: metaObj,
baseModel: img.baseModel || (metaObj && metaObj.Model) || '',
metaLoaded: Boolean(metaObj)
};
});
let startIndex = 0;
if (targetImageId) {
const foundIdx = parsedItems.findIndex((it) => it.imageId === String(targetImageId));
if (foundIdx >= 0) startIndex = foundIdx;
}
this.loadExternalItems(parsedItems, startIndex);
return true;
} catch (err) {
this.showPageNotice(`Failed to load Civitai link: ${err.message || 'Network error'}`);
return false;
} finally {
if (loader) loader.style.opacity = '0';
}
}
loadExternalItems(items, startIndex = 0) {
if (!Array.isArray(items) || items.length === 0) return;
this.items = items;
this.seenIds.clear();
items.forEach((it) => this.seenIds.add(it.id));
this.currentIndex = (startIndex >= 0 && startIndex < items.length) ? startIndex : 0;
this.updateFilmstrip();
this.updateCounter();
if (!this.overlay.classList.contains('ct-active')) {
this.open(this.currentIndex);
} else {
this.renderCurrentSlide();
}
}
close() {
this.pause();
if (this.isDragging) {
this.handleMouseUp();
}
document.documentElement.classList.remove('ct-open-locked');
document.body.classList.remove('ct-open-locked');
this.overlay.classList.remove('ct-active');
this.overlay.removeAttribute('role');
this.overlay.removeAttribute('aria-modal');
this.overlay.setAttribute('aria-hidden', 'true');
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
const vid = this.mediaCanvas.querySelector('video');
if (vid) {
vid.pause();
vid.removeAttribute('src');
vid.load();
}
clearTimeout(this.loaderTimer);
const loaderEl = this.overlay.querySelector('#ct-stage-loader');
if (loaderEl) loaderEl.classList.remove('ct-visible');
this.mediaCanvas.innerHTML = '';
this.ambientGlow.style.backgroundImage = 'none';
this.overlay.querySelectorAll('.ct-flank-preview').forEach(el => { el.innerHTML = ''; });
this.overlay.querySelector('#ct-stage').classList.remove('ct-has-flanks');
this.zoom = 1.0;
this.panX = 0;
this.panY = 0;
this.applyTransform();
}
setCacheItem(key, img) {
if (!key || !img) return;
this.pendingPreloads.delete(key);
if (this.imageCache.has(key)) this.imageCache.delete(key);
this.imageCache.set(key, img);
while (this.imageCache.size > this.cacheLimit) {
const oldestKey = this.imageCache.keys().next().value;
this.imageCache.delete(oldestKey);
}
}
getCacheItem(key) {
if (!key || !this.imageCache.has(key)) return null;
const img = this.imageCache.get(key);
this.imageCache.delete(key);
this.imageCache.set(key, img);
return img;
}
renderCurrentSlide() {
const item = this.items[this.currentIndex];
if (!item) return;
const requestId = ++this.slideRequestId;
// Reset Zoom & Pan on slide change
this.zoom = 1.0;
this.panX = 0;
this.panY = 0;
this.applyTransform();
this.updateCounter();
this.resetTimer();
// Stop + release any previous video before replacing the stage contents
const oldVideo = this.mediaCanvas.querySelector('video');
if (oldVideo) oldVideo.pause();
// Ambient Dual-Layer Glow (use thumbnail or HD url)
this.ambientGlow.style.backgroundImage = cssImageUrl(item.thumbnail || item.url);
// 1. Update flanks immediately with known aspect ratio (zero popping/shifting)
let isPortrait = item.isPortrait;
let natW = item.width || 0;
let natH = item.height || 0;
const cachedImg = this.getCacheItem(item.url);
if (cachedImg && cachedImg.naturalWidth > 0) {
natW = cachedImg.naturalWidth;
natH = cachedImg.naturalHeight;
isPortrait = natH > natW;
item.isPortrait = isPortrait;
}
this.updateFlanks(Boolean(isPortrait), natW, natH);
const loaderEl = this.overlay.querySelector('#ct-stage-loader');
clearTimeout(this.loaderTimer);
const mediaAlt = item.username ? `Artwork by ${item.username}` : 'Civitai Artwork';
if (item.type === 'video') {
if (loaderEl) loaderEl.classList.remove('ct-visible');
this.mediaCanvas.innerHTML = '';
const video = document.createElement('video');
video.className = 'ct-media-video';
video.src = sanitizeMediaUrl(item.url);
if (item.poster || item.thumbnail) {
video.poster = sanitizeMediaUrl(item.poster || item.thumbnail);
}
video.autoplay = true;
video.loop = !this.isPlaying || this.intervalMs <= 0;
video.muted = this.isMuted;
video.playsInline = true;
const syncVideo = () => {
if (this.slideRequestId !== requestId) return;
if (video.videoWidth > 0 && video.videoHeight > 0) {
item.isPortrait = video.videoHeight > video.videoWidth;
this.updateFlanks(item.isPortrait, video.videoWidth, video.videoHeight);
}
// Let the clip play to completion before advancing (instead of cutting at 5s)
if (this.isPlaying && this.intervalMs > 0 && Number.isFinite(video.duration) && video.duration > 0) {
this.startTimer(Math.max(1200, video.duration * 1000));
}
};
if (video.readyState >= 1) {
syncVideo();
} else {
video.onloadedmetadata = syncVideo;
}
this.mediaCanvas.appendChild(video);
this.muteBtn.style.display = 'inline-flex';
this.updateMuteIcon();
video.addEventListener('ended', () => {
if (this.slideRequestId === requestId && this.isPlaying && this.intervalMs > 0) {
this.next();
}
});
} else {
// IMAGE HANDLING: GUARANTEE IMAGE IS LOADED BEFORE DISPLAYING
this.muteBtn.style.display = 'none';
// Check if high-res image is already decoded & in memory
if (cachedImg && cachedImg.complete && cachedImg.naturalWidth > 0) {
if (loaderEl) loaderEl.classList.remove('ct-visible');
cachedImg.className = 'ct-media-img ct-loaded';
cachedImg.alt = mediaAlt;
cachedImg.loading = 'eager';
cachedImg.decoding = 'async';
item.isPortrait = cachedImg.naturalHeight > cachedImg.naturalWidth;
this.updateFlanks(item.isPortrait, cachedImg.naturalWidth, cachedImg.naturalHeight);
this.mediaCanvas.innerHTML = '';
this.mediaCanvas.appendChild(cachedImg);
} else {
// If not in cache yet:
// Display the cached thumbnail immediately so the screen is NEVER empty!
if (item.thumbnail) {
this.mediaCanvas.innerHTML = `
`;
}
// Show loader if fetch takes > 180ms
this.loaderTimer = setTimeout(() => {
if (this.slideRequestId === requestId && loaderEl) {
loaderEl.classList.add('ct-visible');
}
}, 180);
// Preload and decode high-res offscreen before showing
const preImg = new Image();
preImg.decoding = 'async';
let settled = false;
const onLoaded = () => {
if (settled) return;
settled = true;
this.setCacheItem(item.url, preImg);
if (preImg.naturalWidth > 0) {
item.isPortrait = preImg.naturalHeight > preImg.naturalWidth;
}
if (this.slideRequestId !== requestId) return; // Stale request, ignore DOM update
clearTimeout(this.loaderTimer);
if (loaderEl) loaderEl.classList.remove('ct-visible');
this.updateFlanks(item.isPortrait, preImg.naturalWidth, preImg.naturalHeight);
// Adopt the already-decoded Image node (no duplicate decode / network fetch)
preImg.className = 'ct-media-img';
preImg.alt = mediaAlt;
preImg.loading = 'eager';
this.mediaCanvas.innerHTML = '';
this.mediaCanvas.appendChild(preImg);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (this.slideRequestId === requestId) {
preImg.classList.add('ct-loaded');
}
});
});
};
preImg.onload = onLoaded;
preImg.onerror = () => {
if (settled) return;
settled = true;
this.pendingPreloads.delete(item.url);
if (this.slideRequestId !== requestId) return;
clearTimeout(this.loaderTimer);
if (loaderEl) loaderEl.classList.remove('ct-visible');
if (item.thumbnail) {
const img = document.createElement('img');
img.className = 'ct-media-img ct-loaded';
img.src = sanitizeMediaUrl(item.thumbnail);
img.alt = mediaAlt;
this.mediaCanvas.innerHTML = '';
this.mediaCanvas.appendChild(img);
}
};
preImg.src = item.url;
if (preImg.complete && preImg.naturalWidth > 0) {
onLoaded();
}
}
}
// Aggressive multi-directional preloading (+1, +2, +3, -1, -2)
this.preloadMedia(this.currentIndex);
this.updateFilmstripActive();
if (this.isInspectorOpen) {
this.renderInspector(item);
}
// Infinite Scroll Buffer
if (this.items.length >= 8 && this.currentIndex >= this.items.length - 3) {
window.scrollBy({ top: window.innerHeight, behavior: 'instant' });
}
}
refreshFlanks() {
const img = this.mediaCanvas.querySelector('img:not(.ct-thumbnail-preview)');
const video = this.mediaCanvas.querySelector('video');
if (img && img.naturalWidth > 0) {
this.updateFlanks(img.naturalHeight > img.naturalWidth, img.naturalWidth, img.naturalHeight);
} else if (video && video.videoWidth > 0) {
this.updateFlanks(video.videoHeight > video.videoWidth, video.videoWidth, video.videoHeight);
}
}
updateFlanks(isPortrait = false, naturalW = 0, naturalH = 0) {
const stage = this.overlay.querySelector('#ct-stage');
const prevInner = this.overlay.querySelector('#ct-flank-prev-inner');
const nextInner = this.overlay.querySelector('#ct-flank-next-inner');
if (!stage || !prevInner || !nextInner) return;
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
const viewportRatio = viewportW / viewportH;
let imageRatio = 0.6; // Default portrait aspect ratio
if (naturalW > 0 && naturalH > 0) {
imageRatio = naturalW / naturalH;
} else if (!isPortrait) {
imageRatio = 1.77; // Default landscape aspect ratio
}
// True tall vs wide determination against current viewport ratio
const isTall = imageRatio < viewportRatio;
// 1. Enforce 100% Full Height (for tall) or 100% Full Width (for wide)
this.mediaCanvas.classList.toggle('ct-tall-media', isTall);
this.mediaCanvas.classList.toggle('ct-wide-media', !isTall);
// 2. Compute rendered media width and remaining leftover horizontal space
const renderedMediaWidth = isTall ? (viewportH * imageRatio) : viewportW;
const leftoverPerSide = Math.max(0, (viewportW - renderedMediaWidth) / 2);
// 3. Flanks are active ONLY if there is ample leftover space (>= 100px) on wide desktop screens (>= 1024px)
const isWidescreen = viewportW >= 1024;
const canShowFlanks = isWidescreen && isTall && leftoverPerSide >= 100 && this.items.length >= 2;
if (!canShowFlanks) {
stage.classList.remove('ct-has-flanks');
prevInner.innerHTML = '';
nextInner.innerHTML = '';
return;
}
const prevIdx = (this.currentIndex - 1 + this.items.length) % this.items.length;
const nextIdx = (this.currentIndex + 1) % this.items.length;
const prevItem = this.items[prevIdx];
const nextItem = this.items[nextIdx];
// Use thumbnail or cached image so flanks render in 0ms without ever being empty
const pickSrc = (it) => sanitizeMediaUrl(it.thumbnail) || sanitizeMediaUrl(it.type === 'video' ? it.poster : it.url) || sanitizeMediaUrl(it.url);
const prevSrc = pickSrc(prevItem);
const nextSrc = pickSrc(nextItem);
const currentPrev = prevInner.querySelector('img');
if (!currentPrev || currentPrev.getAttribute('data-id') !== prevItem.id) {
prevInner.innerHTML = `
`;
}
const currentNext = nextInner.querySelector('img');
if (!currentNext || currentNext.getAttribute('data-id') !== nextItem.id) {
nextInner.innerHTML = `
`;
}
stage.classList.add('ct-has-flanks');
}
preloadImage(url, item = null) {
const safeUrl = sanitizeMediaUrl(url);
if (!safeUrl || this.imageCache.has(safeUrl) || this.pendingPreloads.has(safeUrl)) return;
this.pendingPreloads.add(safeUrl);
const img = new Image();
img.decoding = 'async';
const settle = () => {
if (img.naturalWidth > 0) {
if (item && item.isPortrait === null) {
item.isPortrait = img.naturalHeight > img.naturalWidth;
}
this.setCacheItem(safeUrl, img);
} else {
this.pendingPreloads.delete(safeUrl);
}
};
img.onload = () => {
if (img.decode) {
img.decode().then(settle).catch(settle);
} else {
settle();
}
};
img.onerror = () => this.pendingPreloads.delete(safeUrl);
img.src = safeUrl;
}
preloadMedia(index) {
if (!this.items || this.items.length === 0) return;
// Bidirectional preloading: 3 forward, 2 backward (deduped for tiny galleries)
const count = this.items.length;
const indices = [...new Set([
(index + 1) % count,
(index - 1 + count) % count,
(index + 2) % count,
(index - 2 + count) % count,
(index + 3) % count
])].filter(idx => idx !== index);
indices.forEach(idx => {
const it = this.items[idx];
if (!it) return;
if (it.type === 'image') {
this.preloadImage(it.url, it);
}
// Preload thumbnail for flanks / filmstrip (tiny, cheap)
this.preloadImage(it.thumbnail);
});
}
next() {
if (this.items.length === 0) return;
if (this.isShuffled) {
let rand = Math.floor(Math.random() * this.items.length);
if (rand === this.currentIndex && this.items.length > 1) {
rand = (rand + 1) % this.items.length;
}
this.currentIndex = rand;
} else {
this.currentIndex = (this.currentIndex + 1) % this.items.length;
}
this.renderCurrentSlide();
}
prev() {
if (this.items.length === 0) return;
this.currentIndex = (this.currentIndex - 1 + this.items.length) % this.items.length;
this.renderCurrentSlide();
}
play() {
this.isPlaying = true;
this.playBtn.innerHTML = ICONS.pause;
this.playBtn.title = 'Pause (Space)';
const video = this.mediaCanvas.querySelector('video');
if (video) {
video.loop = this.intervalMs <= 0;
const playPromise = video.play();
if (playPromise && playPromise.catch) playPromise.catch(() => {});
}
this.handleUserActivity();
if (video && this.intervalMs > 0 && Number.isFinite(video.duration) && video.duration > 0) {
const remainingMs = Math.max(1200, (video.duration - video.currentTime) * 1000);
this.startTimer(remainingMs);
} else {
this.startTimer();
}
}
pause() {
this.isPlaying = false;
this.playBtn.innerHTML = ICONS.play;
this.playBtn.title = 'Play (Space)';
const video = this.mediaCanvas.querySelector('video');
if (video) video.pause();
if (this.progressFill) {
const computedWidth = window.getComputedStyle(this.progressFill).width;
this.progressFill.style.transition = 'none';
this.progressFill.style.width = computedWidth;
}
this.clearTimers();
this.overlay.classList.remove('ct-hud-hidden');
}
togglePlay() {
if (this.isPlaying) this.pause();
else this.play();
}
setSpeed(ms) {
this.intervalMs = ms;
const video = this.mediaCanvas.querySelector('video');
if (video) video.loop = this.intervalMs <= 0;
this.resetTimer();
if (this.intervalMs === 0) this.pause();
else if (!this.isPlaying) this.play();
}
startTimer(durationMs = this.intervalMs) {
this.clearTimers();
if (durationMs <= 0 || !this.isPlaying) {
if (this.progressFill) {
this.progressFill.style.transition = 'none';
this.progressFill.style.width = '0%';
}
return;
}
if (this.progressFill) {
this.progressFill.style.transition = 'none';
this.progressFill.style.width = '0%';
// Force reflow for deterministic CSS transition restart
void this.progressFill.offsetWidth;
this.progressFill.style.transition = `width ${durationMs}ms linear`;
this.progressFill.style.width = '100%';
}
this.progressTimer = setTimeout(() => {
if (this.isPlaying) {
this.next();
}
}, durationMs);
}
resetTimer() {
this.clearTimers();
if (this.progressFill) {
this.progressFill.style.transition = 'none';
this.progressFill.style.width = '0%';
}
if (this.isPlaying && this.intervalMs > 0) {
this.startTimer();
}
}
clearTimers() {
if (this.progressTimer) {
clearTimeout(this.progressTimer);
this.progressTimer = null;
}
}
toggleMute() {
this.isMuted = !this.isMuted;
const video = this.mediaCanvas.querySelector('video');
if (video) video.muted = this.isMuted;
this.updateMuteIcon();
}
updateMuteIcon() {
this.muteBtn.innerHTML = this.isMuted ? ICONS.volumeX : ICONS.volume2;
this.muteBtn.title = this.isMuted ? 'Unmute Audio (M)' : 'Mute Audio (M)';
this.muteBtn.classList.toggle('ct-active', !this.isMuted);
}
toggleFullscreen() {
const fsBtn = this.overlay.querySelector('#ct-btn-fs');
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => {
fsBtn.innerHTML = ICONS.minimize;
}).catch(() => {});
} else {
document.exitFullscreen().then(() => {
fsBtn.innerHTML = ICONS.maximize;
}).catch(() => {});
}
}
toggleZenMode() {
this.isZenMode = !this.isZenMode;
this.overlay.querySelector('#ct-dock-zen').classList.toggle('ct-active', this.isZenMode);
if (this.isZenMode) {
this.toggleInspector(false);
this.toggleFilmstrip(false);
this.overlay.classList.add('ct-hud-hidden');
} else {
this.overlay.classList.remove('ct-hud-hidden');
}
}
toggleFilmstrip(force = null) {
this.isFilmstripOpen = force !== null ? force : !this.isFilmstripOpen;
this.filmstrip.classList.toggle('ct-open', this.isFilmstripOpen);
this.filmstripBtn.classList.toggle('ct-active', this.isFilmstripOpen);
this.overlay.classList.toggle('ct-filmstrip-open', this.isFilmstripOpen);
if (this.isFilmstripOpen) {
this.updateFilmstrip();
}
}
updateFilmstrip() {
if (!this.isFilmstripOpen) return;
this.filmstrip.innerHTML = '';
this.items.forEach((item, index) => {
const card = document.createElement('div');
card.className = `ct-thumb-card ${index === this.currentIndex ? 'ct-active' : ''}`;
card.setAttribute('role', 'button');
card.tabIndex = 0;
card.setAttribute('aria-label', item.username ? `Artwork by ${item.username}` : `Artwork ${index + 1}`);
const thumbSrc = sanitizeMediaUrl(item.type === 'video' ? (item.poster || item.thumbnail) : item.thumbnail);
if (thumbSrc) {
const img = document.createElement('img');
img.src = thumbSrc;
img.alt = '';
img.loading = 'lazy';
img.decoding = 'async';
card.appendChild(img);
} else {
const placeholder = document.createElement('div');
placeholder.className = 'ct-thumb-video-placeholder';
placeholder.innerHTML = ICONS.film;
card.appendChild(placeholder);
}
if (item.type === 'video') {
const badge = document.createElement('div');
badge.className = 'ct-thumb-video-badge';
badge.innerHTML = ICONS.play;
card.appendChild(badge);
}
const select = (e) => {
e.stopPropagation();
e.preventDefault();
this.currentIndex = index;
this.renderCurrentSlide();
};
card.addEventListener('click', select);
card.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') select(e);
});
this.filmstrip.appendChild(card);
});
this.scrollActiveThumbIntoView();
}
updateFilmstripActive() {
if (!this.isFilmstripOpen) return;
const cards = this.filmstrip.querySelectorAll('.ct-thumb-card');
cards.forEach((c, i) => c.classList.toggle('ct-active', i === this.currentIndex));
this.scrollActiveThumbIntoView();
}
scrollActiveThumbIntoView() {
const active = this.filmstrip.querySelector('.ct-thumb-card.ct-active');
if (active) {
active.scrollIntoView({ behavior: 'auto', inline: 'center', block: 'nearest' });
}
}
toggleInspector(force = null) {
this.isInspectorOpen = force !== null ? force : !this.isInspectorOpen;
this.inspector.classList.toggle('ct-open', this.isInspectorOpen);
this.inspectBtn.classList.toggle('ct-active', this.isInspectorOpen);
this.overlay.classList.toggle('ct-inspector-open', this.isInspectorOpen);
if (this.isInspectorOpen) {
const item = this.items[this.currentIndex];
this.renderInspector(item);
}
}
switchInspectorTab(tab) {
this.overlay.querySelector('#ct-tab-specs').classList.toggle('ct-active', tab === 'specs');
this.overlay.querySelector('#ct-tab-shortcuts').classList.toggle('ct-active', tab === 'shortcuts');
const item = this.items[this.currentIndex];
if (tab === 'specs') {
this.renderInspector(item);
} else {
this.renderShortcutsTab();
}
}
renderShortcutsTab() {
this.inspectorBody.innerHTML = `
Pro Keyboard Controls
Play / PauseSpace
Next Slide→ / J / Enter
Previous Slide← / K
Toggle FullscreenF
Toggle FilmstripT
Zen Mode (Hide UI)H
Toggle ShuffleS
Video Audio MuteM
Zoom 2x / ResetZ / DblClick
Zoom In / Out / Reset+ / - / 0
Download MediaD
Open Civitai PostO / V
Remix on CivitaiR
Copy Civitai linkC
Share via DecomfyShift + C
Copy promptP
Mobile GesturesSwipe / Pinch
Speed 3s / 5s / 8s1 / 2 / 3
Exit TheaterEsc
`;
}
renderInspector(item) {
if (!item) return;
this.inspectorBody.innerHTML = `
Media Source
${item.type === 'video' ? 'VIDEO' : 'IMAGE'}
${item.username ? `Creator: ${escapeHtml(item.username)}` : 'Civitai Community Showcase'}
${item.imageId ? `
Image ID: ${escapeHtml(item.imageId)}
` : ''}
${item.postId ? `
Post ID: ${escapeHtml(item.postId)}
` : ''}
Positive Prompt
Loading telemetry from Civitai API...
Negative Prompt
ComfyUI Reverse Engineering
DECOMFY
Decompile embedded PNG workflows, extract node DAGs, and resolve missing custom nodes on Decomfy Lab.
`;
if (item.metaLoaded) {
this.renderMetaFields(item);
} else if (item.imageId) {
this.fetchCivitaiMeta(item);
} else {
const pEl = this.inspectorBody.querySelector('#ct-prompt-text');
if (pEl) pEl.textContent = 'No positive prompt recorded for this preview card.';
}
const remixBtn = this.inspectorBody.querySelector('#ct-btn-remix');
if (remixBtn) remixBtn.addEventListener('click', () => this.openRemix());
const drawerShareBtn = this.inspectorBody.querySelector('#ct-btn-share-decomfy-drawer');
if (drawerShareBtn) drawerShareBtn.addEventListener('click', () => this.shareViaDecomfy());
this.bindCopyAction('#ct-copy-prompt', '#ct-prompt-text');
this.bindCopyAction('#ct-copy-neg', '#ct-neg-text');
}
renderMetaFields(item) {
const root = this.inspectorBody;
if (!root) return;
const meta = (item.meta && typeof item.meta === 'object') ? item.meta : {};
const pEl = root.querySelector('#ct-prompt-text');
if (pEl) {
pEl.textContent = meta.prompt ? String(meta.prompt) : 'Standard Civitai Showcase item.';
}
const negCard = root.querySelector('#ct-card-negative');
const negEl = root.querySelector('#ct-neg-text');
const negative = meta.negativePrompt ? String(meta.negativePrompt) : '';
if (negCard && negEl) {
negCard.style.display = negative ? 'block' : 'none';
negEl.textContent = negative;
}
// Extract LoRAs from meta.resources and prompt tags
const loras = [];
const seenLoras = new Set();
if (Array.isArray(meta.resources)) {
meta.resources.forEach(r => {
if (r && r.name) {
const name = String(r.name).trim();
if (!seenLoras.has(name.toLowerCase())) {
seenLoras.add(name.toLowerCase());
const w = r.weight != null ? String(r.weight) : (r.strength != null ? String(r.strength) : '1.0');
loras.push({ name, weight: w });
}
}
});
}
const promptStr = meta.prompt ? String(meta.prompt) : '';
const loraRegex = /<\s*lora\s*:\s*([^:>]+)(?::\s*([^>]+))?\s*>/gi;
let lMatch;
while ((lMatch = loraRegex.exec(promptStr)) !== null) {
const name = lMatch[1].trim();
if (!seenLoras.has(name.toLowerCase())) {
seenLoras.add(name.toLowerCase());
loras.push({ name, weight: lMatch[2] ? lMatch[2].trim() : '1.0' });
}
}
const loraCard = root.querySelector('#ct-card-loras');
const loraList = root.querySelector('#ct-loras-list');
const loraCount = root.querySelector('#ct-loras-count');
if (loraCard && loraList) {
loraCard.style.display = loras.length ? 'block' : 'none';
if (loraCount) loraCount.textContent = loras.length ? `${loras.length} LORAS` : '';
loraList.innerHTML = loras.map(l => `
${escapeHtml(l.name)}
${escapeHtml(l.weight)}
`).join('');
}
const stats = [];
if (item.baseModel) stats.push(['Base Model', item.baseModel]);
if (meta.Model) stats.push(['Checkpoint', meta.Model]);
const res = meta.Size || (meta.width && meta.height ? `${meta.width}x${meta.height}` : '');
if (res) stats.push(['Resolution', res]);
if (meta.steps != null) stats.push(['Steps', meta.steps]);
const cfg = meta.cfgScale ?? meta['CFG scale'] ?? meta.cfg_scale;
if (cfg != null) stats.push(['CFG Scale', cfg]);
if (meta.sampler) stats.push(['Sampler', meta.sampler]);
const clip = meta.clipSkip ?? meta['Clip skip'];
if (clip != null) stats.push(['Clip Skip', clip]);
if (meta.seed != null) stats.push(['Seed', meta.seed]);
const paramsCard = root.querySelector('#ct-card-params');
const grid = root.querySelector('#ct-params-grid');
if (paramsCard && grid) {
paramsCard.style.display = stats.length ? 'block' : 'none';
grid.innerHTML = stats.map(([label, value]) => `
`).join('');
}
}
bindCopyAction(btnSel, textSel) {
const btn = this.inspectorBody.querySelector(btnSel);
if (!btn) return;
btn.addEventListener('click', () => {
const text = this.inspectorBody.querySelector(textSel)?.textContent || '';
navigator.clipboard.writeText(text).then(() => {
btn.innerHTML = `${ICONS.check} Copied`;
setTimeout(() => { btn.innerHTML = `${ICONS.copy} Copy`; }, 1600);
}).catch(() => {});
});
}
async fetchCivitaiMeta(item) {
if (!item || item.metaLoaded || item.metaFetching) return;
item.metaFetching = true;
try {
const res = await fetch(`https://civitai.com/api/v1/images?imageId=${encodeURIComponent(item.imageId)}&nsfw=X`, {
credentials: 'omit'
});
if (!res.ok) throw new Error('API failed');
const data = await res.json();
const info = data.items && data.items[0];
let meta = (info && info.meta) || {};
if (typeof meta === 'string') {
try { meta = JSON.parse(meta); } catch (_) { meta = {}; }
}
item.meta = (meta && typeof meta === 'object') ? meta : {};
item.baseModel = (info && info.baseModel) || '';
if (info && info.username) item.username = info.username;
item.metaLoaded = true;
if (this.items[this.currentIndex] === item && this.isInspectorOpen) {
this.renderMetaFields(item);
}
} catch (_) {
if (this.items[this.currentIndex] === item && this.isInspectorOpen) {
const pEl = this.inspectorBody.querySelector('#ct-prompt-text');
if (pEl) pEl.textContent = 'Standard generation telemetry: Civitai Community Showcase.';
}
} finally {
item.metaFetching = false;
}
}
downloadCurrent() {
const item = this.items[this.currentIndex];
if (!item) return;
const href = sanitizeMediaUrl(item.rawUrl || item.url);
if (!href) return;
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.rel = 'noopener noreferrer';
const ext = item.type === 'video' ? 'mp4' : 'jpg';
a.download = `civitai_${item.imageId || this.currentIndex}_${item.username || 'art'}.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
openExternal() {
const item = this.items[this.currentIndex];
if (!item) return;
let targetUrl = '';
if (item.imageId) {
targetUrl = `https://civitai.com/images/${encodeURIComponent(item.imageId)}`;
} else if (item.postId) {
targetUrl = `https://civitai.com/posts/${encodeURIComponent(item.postId)}`;
} else if (item.civitaiUrl) {
targetUrl = item.civitaiUrl;
} else if (item.decomfySlug) {
targetUrl = `https://decomfy.com/${encodeURIComponent(item.decomfySlug)}/`;
} else {
targetUrl = sanitizeMediaUrl(item.rawUrl || item.url);
}
if (!targetUrl) return;
window.open(targetUrl, '_blank', 'noopener,noreferrer');
}
updateCounter() {
if (this.counterEl) {
this.counterEl.textContent = `${this.currentIndex + 1} / ${this.items.length}`;
}
const item = this.items[this.currentIndex];
if (this.headerCivitaiPill) {
if (item && item.imageId) {
this.headerCivitaiPill.href = `https://civitai.com/images/${encodeURIComponent(item.imageId)}`;
if (this.headerCivitaiText) this.headerCivitaiText.textContent = `Civitai #${item.imageId}`;
this.headerCivitaiPill.style.display = 'inline-flex';
} else if (item && item.postId) {
this.headerCivitaiPill.href = `https://civitai.com/posts/${encodeURIComponent(item.postId)}`;
if (this.headerCivitaiText) this.headerCivitaiText.textContent = `Civitai Post #${item.postId}`;
this.headerCivitaiPill.style.display = 'inline-flex';
} else if (item && item.civitaiUrl) {
this.headerCivitaiPill.href = item.civitaiUrl;
if (this.headerCivitaiText) this.headerCivitaiText.textContent = 'Civitai';
this.headerCivitaiPill.style.display = 'inline-flex';
} else {
this.headerCivitaiPill.style.display = 'none';
}
}
}
}
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => new CivitaiTheater());
} else {
new CivitaiTheater();
}
})();