Lineage picker overhaul + Ideogram 4 integration

Viewer lineage picker: centered tidy tree with per-row overflow bundles,
you-are-here orientation (auto-center, active-path highlight, off-branch dimming),
upscale provenance badges, always-visible fork markers, focus/isolate a branch,
multi-select batch delete with confirm, and per-family expansion persistence.

Ideogram 4 model integration: preset, prompts, t2i template, fingerprint
detection, and caption/sampler nodes.
This commit is contained in:
chrmc40
2026-06-19 15:47:13 -07:00
parent 68cbef51ec
commit ad4a5a5004
17 changed files with 3215 additions and 1199 deletions
+3 -1
View File
@@ -13,6 +13,8 @@ from .nodes.attention_couple import AttentionCoupleNode
from .nodes.regional_detailer import RegionalDetailerNode, MaskedDetailNode
from .nodes.regional_conditioning import RegionalConditioningNode
from .nodes.defocus_mask import DefocusMaskNode
from .nodes.ideogram_sampler import IdeogramSamplerNode
from .nodes.ideogram_caption import IdeogramCaptionNode
# Timestamps on every PromptChain log line. ComfyUI's default formatter
@@ -220,7 +222,7 @@ _threading.Thread(target=_preload, daemon=True).start()
class PromptChainExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [PromptChainNode, PoseStudioNode, AttentionCoupleNode, RegionalDetailerNode, MaskedDetailNode, RegionalConditioningNode, DefocusMaskNode]
return [PromptChainNode, PoseStudioNode, AttentionCoupleNode, RegionalDetailerNode, MaskedDetailNode, RegionalConditioningNode, DefocusMaskNode, IdeogramSamplerNode, IdeogramCaptionNode]
async def comfy_entrypoint() -> PromptChainExtension:
+12 -2
View File
@@ -158,8 +158,13 @@ def _detect_safetensor_arch(filepath: str) -> str:
header = _read_safetensor_header(filepath)
if not header:
return "unknown"
tensor_names = [k for k in list(header.keys())[:50] if k != "__metadata__"]
names_str = " ".join(tensor_names)
all_names = [k for k in header.keys() if k != "__metadata__"]
# Ideogram 4's marker tensor sits ~200 keys deep in the fp8 checkpoints (leading
# keys are .weight_scale quant tensors), past the first-50 window below — so check
# the full key set, mirroring ComfyUI's detect_unet_config.
if any("embed_image_indicator." in k for k in all_names):
return "ideogram"
names_str = " ".join(all_names[:50])
for pattern, arch in ARCHITECTURE_PATTERNS:
if pattern in names_str:
if arch == "flux":
@@ -366,6 +371,11 @@ def scan_models() -> int:
cached_mtime = info.get("mtime", 0)
cached_size = info.get("size", -1)
if st.st_mtime == cached_mtime and st.st_size == cached_size:
# Re-detect a previously-unknown arch (cheap header read, no
# re-hash) so fingerprint improvements reclassify old files
# without a full cache wipe.
if info.get("architecture") == "unknown":
info = {**info, "architecture": detect_architecture(filepath)}
new_by_hash[h] = info
new_by_filename[filename.lower()] = h
new_by_path[filepath] = h
+162
View File
@@ -0,0 +1,162 @@
{
"display_name": "Ideogram 4.0",
"model_name": "Ideogram 4.0",
"version": "Base",
"architecture": "ideogram",
"family": "ideogram4",
"author": "Ideogram",
"description": "Ideogram 4.0 open-weight text-to-image model (9.3B single-stream DiT) run locally in ComfyUI. Best-in-class in-image text rendering and typography. Uses a Qwen3-VL-8B text encoder and the Flux 2 VAE, plus a second 'unconditional' transformer that drives dual-branch CFG (there is no user negative prompt). Authored in natural language. Weights are non-commercial; generated images may be used commercially.",
"license": "Ideogram Non-Commercial Model Agreement (inference code Apache-2.0)",
"tags": [
"ideogram",
"ideogram4",
"text-rendering",
"typography",
"open-weight",
"consumer"
],
"url": "https://huggingface.co/Comfy-Org/Ideogram-4",
"release_date": "2026-06-03",
"tag_sources": [],
"tag_format": "spaces",
"prompt_style": "natural",
"trigger": "",
"default_prompt_id": "ideogram-default",
"negative": "",
"notes": "REQUIRES a ComfyUI version with native Ideogram 4 support (the UNETLoader 'ideogram4' arch, CLIPLoader 'ideogram4' type, Ideogram4Scheduler, DualModelGuider, CFGOverride). Weights from the UN-GATED Comfy-Org/Ideogram-4 mirror (no HF token needed). Two transformers load via two UNETLoaders; the unconditional one feeds DualModelGuider.model_negative. Recommended: Default = 20 steps, Turbo = 12, Quality = 48; DualModelGuider guidance 7; CFGOverride [3, 0.7, 1]; scheduler mu 0.5 / std 1.75; sampler euler. Native output up to 2048px. On a 32 GB card (RTX 5090) full fp8 fits with text-encoder offload; drop the unconditional transformer or encoder to nvfp4 if VRAM is tight. NON-COMMERCIAL weights: fine for personal/local use; commercial deployment of the model needs Ideogram's paid license (generated images themselves are fine to use commercially, except training competing models).",
"files": [
{
"label": "Diffusion Model",
"folder": "diffusion_models",
"variants": [
{
"precision": "fp8",
"filename": "ideogram4_fp8_scaled.safetensors",
"quick_hash": "e9380be7fe0165c7",
"size_bytes": 9280741285,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Ideogram-4",
"path": "diffusion_models/ideogram4_fp8_scaled.safetensors"
},
"sha256": null
},
{
"precision": "nvfp4",
"filename": "ideogram4_nvfp4_mixed.safetensors",
"size_bytes": 5490000000,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Ideogram-4",
"path": "diffusion_models/ideogram4_nvfp4_mixed.safetensors"
},
"sha256": null
}
]
},
{
"label": "Unconditional Transformer",
"folder": "diffusion_models",
"variants": [
{
"precision": "fp8",
"filename": "ideogram4_unconditional_fp8_scaled.safetensors",
"quick_hash": "76383548b85c3b56",
"size_bytes": 9280741293,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Ideogram-4",
"path": "diffusion_models/ideogram4_unconditional_fp8_scaled.safetensors"
},
"sha256": null
},
{
"precision": "nvfp4",
"filename": "ideogram4_unconditional_nvfp4_mixed.safetensors",
"size_bytes": 5490000000,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Ideogram-4",
"path": "diffusion_models/ideogram4_unconditional_nvfp4_mixed.safetensors"
},
"sha256": null
}
]
},
{
"label": "Text Encoder (Qwen3-VL-8B)",
"folder": "text_encoders",
"variants": [
{
"precision": "fp8",
"filename": "qwen3vl_8b_fp8_scaled.safetensors",
"size_bytes": 10588637512,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Ideogram-4",
"path": "text_encoders/qwen3vl_8b_fp8_scaled.safetensors"
},
"sha256": null
},
{
"precision": "nvfp4",
"filename": "qwen3vl_8b_nvfp4.safetensors",
"size_bytes": 6310000000,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Ideogram-4",
"path": "text_encoders/qwen3vl_8b_nvfp4.safetensors"
},
"sha256": null
}
]
},
{
"label": "VAE",
"folder": "vae",
"filename": "flux2-vae.safetensors",
"size_bytes": 336213556,
"source": {
"provider": "huggingface",
"repo": "Comfy-Org/Ideogram-4",
"path": "vae/flux2-vae.safetensors"
},
"sha256": null
}
],
"nodes": {
"Ideogram4Scheduler": {
"steps": 20,
"steps_range": [
12,
48
]
},
"EmptyFlux2LatentImage": {
"width": 1024,
"height": 1024,
"_resolutions": [
{
"width": 1024,
"height": 1024
},
{
"width": 896,
"height": 1152
},
{
"width": 1152,
"height": 896
},
{
"width": 832,
"height": 1216
},
{
"width": 1216,
"height": 832
}
]
}
}
}
+385
View File
@@ -0,0 +1,385 @@
{
"name": "Ideogram 4 Prompts",
"prompts": [
{
"id": "ideogram-default",
"name": "Default",
"category": "Photographic",
"text": "// Your description\n{cursor}\n\n// Photographic - Default\nA candid natural-light photograph, soft daylight, true-to-life color and texture, sharp focus with a gently blurred background, relaxed off-center framing.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-photographic-cinematic",
"name": "Cinematic",
"category": "Photographic",
"text": "// Your description\n{cursor}\n\n// Photographic - Cinematic\nA cinematic film still, moody low-key lighting, soft teal-and-amber tones, atmospheric depth, a wide dramatic composition, a contemplative mood.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-photographic-studio_portrait",
"name": "Studio Portrait",
"category": "Photographic",
"text": "// Your description\n{cursor}\n\n// Photographic - Studio Portrait\nA clean studio portrait on a seamless grey backdrop, soft key light with a gentle rim light, natural skin detail, a softly blurred background, refined neutral color.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-photographic-product_shot",
"name": "Product Shot",
"category": "Photographic",
"text": "// Your description\n{cursor}\n\n// Photographic - Product Shot\nA clean studio product photo, the subject centered on a soft seamless gradient, gentle rim light and a subtle reflection beneath, crisp detail and true material color, generous negative space.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-photographic-editorial_fashion",
"name": "Editorial Fashion",
"category": "Photographic",
"text": "// Your description\n{cursor}\n\n// Photographic - Editorial Fashion\nA high-fashion editorial photo, dramatic studio lighting with crisp shadows, confident styling, a refined palette, a clean set, a polished glossy finish.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-poster",
"name": "Poster",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Poster\nA bold graphic poster, large title 'YOUR TITLE' across the top and 'your subtitle here' below, a striking central subject, screen-print texture with flat high-contrast color blocking, strong typographic hierarchy, confident limited palette (color palette #1B1035, #FF2E97, #00E5FF). Portrait 2:3.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-logo_wordmark",
"name": "Logo / Wordmark",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Logo / Wordmark\nA minimalist flat-vector logo, the wordmark 'BRAND NAME' in clean bold sans-serif with a small simple iconic mark above it, two-tone monochrome palette, perfectly centered with generous whitespace on a plain white background, sharp crisp edges, balanced professional brand mark. 1:1.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-badge_emblem",
"name": "Badge / Emblem",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Badge / Emblem\nA vintage badge emblem with 'EST. 2026' curved along the top and 'YOUR GUILD' curved along the bottom, a central engraving-style icon, distressed letterpress texture, two-tone cream and dark navy, symmetrical centered composition. 1:1.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-album_cover",
"name": "Album Cover",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Album Cover\nA square album cover, bold display title 'ALBUM NAME' centered in glowing chrome 3D type, dark gradient background with film grain, dramatic studio lighting and high contrast, color palette #0A0A0A, #00E5FF, #FF2E97. 1:1.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-book_cover",
"name": "Book Cover",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Book Cover\nA book cover on a deep navy background (#0D1B2A), a bold centered two-line white title 'THE TITLE' then 'SECOND LINE' with the second line larger, a smaller grey subtitle beneath 'your subtitle', a central minimalist gold geometric illustration, elegant serif typography. Portrait 2:3.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-magazine_cover",
"name": "Magazine Cover",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Magazine Cover\nA premium magazine cover, large bold masthead 'TITLE' across the top and a smaller tagline 'your tagline' beneath, a striking close-up editorial portrait, dramatic studio lighting with crisp shadows, refined sophisticated palette, clean grid with generous margins, glossy print finish. Portrait 3:4.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-packaging",
"name": "Packaging",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Packaging\nA product package design, kraft-paper texture background, a centered bold vintage serif name 'PRODUCT NAME' with a small-caps subline 'your descriptor' below, a central hand-drawn engraving-style illustration, warm earthy palette, balanced label layout. 4:5.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-infographic",
"name": "Infographic",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Infographic\nA clean modern infographic, title 'HOW IT WORKS' with three numbered step labels '1 Create', '2 Convert', '3 Export', simple flat icons, generous white space, a single accent color (color palette #00D1B2, #111111, #FFFFFF), tidy aligned grid. 4:5.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-sticker",
"name": "Sticker",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Sticker\nA die-cut sticker with a thick clean white border, bold flat cartoon shapes and cheerful saturated colors, a single centered subject, simple chunky linework, an optional all-caps word 'YOUR WORD' in bold condensed type, soft even lighting, glossy vinyl finish. 1:1.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-signage",
"name": "Signage",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Signage\nA modern signage design, the word 'WELCOME' large and centered with a smaller line 'your second line' beneath, high-contrast lettering legible at a glance, clean museum-style layout, simple solid background. 16:9.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-design-greeting_card",
"name": "Greeting Card",
"category": "Design",
"text": "// Your description\n{cursor}\n\n// Design - Greeting Card\nA charming greeting card, a friendly central illustration with a hand-lettered message 'YOUR MESSAGE' above it, soft warm palette, plenty of breathing room, gentle decorative accents, balanced symmetrical layout. Portrait 4:5.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-typography-bold_headline",
"name": "Bold Headline",
"category": "Typography",
"text": "// Your description\n{cursor}\n\n// Typography - Bold Headline\nLarge bold uppercase sans-serif headline reading 'YOUR TEXT', clean crisp letterforms with tight kerning, high-contrast color on a simple flat background, strong centered composition, sharp edges, modern editorial type treatment.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-typography-3d_inflated_type",
"name": "3D Inflated Type",
"category": "Typography",
"text": "// Your description\n{cursor}\n\n// Typography - 3D Inflated Type\nMassive puffy inflated 3D balloon letters spelling 'YOUR TEXT', a soft glossy plastic surface with smooth highlights and gentle shadows, playful rounded forms filling the frame, a bright cheerful palette, soft studio lighting.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-typography-hand_lettered_script",
"name": "Hand-Lettered Script",
"category": "Typography",
"text": "// Your description\n{cursor}\n\n// Typography - Hand-Lettered Script\nElegant hand-lettered script spelling 'YOUR TEXT', flowing brush-pen strokes with graceful flourishes and natural ink texture, a warm cream paper background, refined calligraphic composition, an artisanal feel.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-typography-neon_sign",
"name": "Neon Sign",
"category": "Typography",
"text": "// Your description\n{cursor}\n\n// Typography - Neon Sign\nA glowing neon sign spelling 'YOUR TEXT' in vibrant cursive tube lighting, a rich magenta-and-cyan glow reflecting on a dark brick wall, soft atmospheric haze, high-contrast night mood, realistic light bloom.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-typography-vintage_letterpress",
"name": "Vintage Letterpress",
"category": "Typography",
"text": "// Your description\n{cursor}\n\n// Typography - Vintage Letterpress\nDistressed vintage letterpress type spelling 'YOUR TEXT', a bold condensed serif with ink texture and a subtle paper deboss, a muted two-tone palette, a classic centered poster layout, a weathered handcrafted print feel.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-typography-chrome_display",
"name": "Chrome Display",
"category": "Typography",
"text": "// Your description\n{cursor}\n\n// Typography - Chrome Display\nSleek liquid-chrome 3D display type spelling 'YOUR TEXT', mirror-polished metal with sharp specular highlights and reflections, a dark gradient background, dramatic rim lighting, a high-gloss futuristic finish.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-illustration-flat_vector",
"name": "Flat Vector",
"category": "Illustration",
"text": "// Your description\n{cursor}\n\n// Illustration - Flat Vector\nDesigned as flat vector illustration with bold clean outlines, simple geometric shapes and large solid color fills, generous negative space, no gradients or texture, even flat lighting, a minimal modern editorial layout and a confident limited palette.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-illustration-anime_manga",
"name": "Anime / Manga",
"category": "Illustration",
"text": "// Your description\n{cursor}\n\n// Illustration - Anime / Manga\nIllustrated in clean cel-shaded anime style with confident crisp linework, large expressive eyes and flat vibrant color fills, soft cinematic rim lighting and gentle gradient shadows, Studio Ghibli-inspired warmth, a dynamic three-quarter composition.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-illustration-comic_graphic_novel",
"name": "Comic / Graphic Novel",
"category": "Illustration",
"text": "// Your description\n{cursor}\n\n// Illustration - Comic / Graphic Novel\nDrawn in bold comic-book style with heavy black ink outlines, dramatic cross-hatching and crisp cel shading, vivid flat colors with halftone dot texture, a high-contrast dynamic composition and strong directional lighting, graphic-novel ink-and-color energy.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-illustration-watercolor",
"name": "Watercolor",
"category": "Illustration",
"text": "// Your description\n{cursor}\n\n// Illustration - Watercolor\nPainted in ink and watercolor with fine crisp linework, soft translucent washes that bleed and pool, visible paper texture and granulating pigment, gentle natural light, loose painterly edges, a delicate romantic mood and an airy pastel palette.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-illustration-oil_painting",
"name": "Oil Painting",
"category": "Illustration",
"text": "// Your description\n{cursor}\n\n// Illustration - Oil Painting\nRendered as a textured oil painting with thick visible impasto brushstrokes and rich blended color, warm directional studio light raking across the canvas, deep saturated darks and luminous highlights, a painterly classical composition, an old-master gallery atmosphere.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-illustration-children_s_book",
"name": "Children's Book",
"category": "Illustration",
"text": "// Your description\n{cursor}\n\n// Illustration - Children's Book\nIllustrated in warm storybook style with soft hand-painted gouache textures, rounded friendly shapes and gentle cozy color, soft diffuse daylight, a charming whimsical composition with plenty of breathing room, a tender heartwarming picture-book mood.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-illustration-concept_art",
"name": "Concept Art",
"category": "Illustration",
"text": "// Your description\n{cursor}\n\n// Illustration - Concept Art\nRendered as painterly digital concept art with loose confident brushwork and soft atmospheric edges, tonal value blocking, dramatic cinematic lighting and deep aerial perspective, an epic widescreen establishing composition, a moody evocative atmosphere.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-illustration-fantasy_art",
"name": "Fantasy Art",
"category": "Illustration",
"text": "// Your description\n{cursor}\n\n// Illustration - Fantasy Art\nPainted as epic fantasy illustration with luminous golden-hour light and glowing magical accents, a rich jewel-toned palette and intricate ornate detail, a sweeping dramatic vista with atmospheric depth, a heroic awe-inspiring storybook mood.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-3d_render-3d_render",
"name": "3D Render",
"category": "3D & Render",
"text": "// Your description\n{cursor}\n\n// 3D & Render - 3D Render\nRendered as a polished 3D scene with smooth subsurface-scattered surfaces, soft global illumination and gentle ambient occlusion, glossy and matte material contrast, a dramatic studio key light on a clean gradient backdrop, crisp reflections, high-contrast stylized realism.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-3d_render-isometric",
"name": "Isometric",
"category": "3D & Render",
"text": "// Your description\n{cursor}\n\n// 3D & Render - Isometric\nComposed as a clean isometric illustration at a true 30-degree angle, a soft pastel palette and smooth vector shading with subtle clay-render volume, even diffuse lighting and gentle long shadows, a tidy miniature-diorama layout on a plain white background.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-3d_render-pixel_art",
"name": "Pixel Art",
"category": "3D & Render",
"text": "// Your description\n{cursor}\n\n// 3D & Render - Pixel Art\nRendered as crisp 16-bit pixel art on a tight grid, a limited indexed color palette with dithered shading, clean hard-edged sprites and chunky readable forms, flat retro-game lighting, a centered composition against a simple background, a nostalgic arcade mood.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-3d_render-clay_figurine",
"name": "Clay Figurine",
"category": "3D & Render",
"text": "// Your description\n{cursor}\n\n// 3D & Render - Clay Figurine\nRendered as a cute claymation figurine sculpted from smooth matte polymer clay, soft rounded handmade forms with gentle fingerprint texture, soft studio lighting on a simple backdrop, a miniature diorama feel, a charming stop-motion look.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-aesthetic-cyberpunk",
"name": "Cyberpunk",
"category": "Aesthetic",
"text": "// Your description\n{cursor}\n\n// Aesthetic - Cyberpunk\nRendered in neon-soaked cyberpunk style with glowing magenta-and-cyan signage reflecting on wet surfaces, dense atmospheric haze and volumetric light, high-contrast darkness pierced by holographic glow, a gritty futuristic city composition, an electric dystopian mood.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-aesthetic-minimalist",
"name": "Minimalist",
"category": "Aesthetic",
"text": "// Your description\n{cursor}\n\n// Aesthetic - Minimalist\nComposed as a clean minimalist design with a single subject and abundant negative space, a soft gradient or solid background, a restrained two-or-three-color palette, flat even lighting, precise balanced placement, an elegant calm uncluttered mood.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-aesthetic-synthwave_retro",
"name": "Synthwave / Retro",
"category": "Aesthetic",
"text": "// Your description\n{cursor}\n\n// Aesthetic - Synthwave / Retro\nRendered in 1980s synthwave style with a glowing neon grid horizon and a setting-sun gradient, chrome accents and purple-and-pink haze, bold retro-futuristic composition, scanline texture, a nostalgic outrun mood. Color palette #1B1035, #FF2E97, #00E5FF.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
},
{
"id": "ideogram-aesthetic-risograph_screenprint",
"name": "Risograph / Screenprint",
"category": "Aesthetic",
"text": "// Your description\n{cursor}\n\n// Aesthetic - Risograph / Screenprint\nRendered as a risograph screen-print with a limited 2-3 ink palette, grainy halftone texture and slight misregistration, bold flat shapes with overprint blends, matte paper feel, a handmade indie-poster aesthetic.",
"scope": {
"type": "architecture",
"architecture": "ideogram"
}
}
]
}
+139
View File
@@ -0,0 +1,139 @@
{
"name": "Text-to-Image",
"category": "Generation",
"order": 0,
"scope": {
"type": "architecture",
"architecture": "ideogram"
},
"created_at": 1781740800,
"id": "ideogram-t2i",
"prompt_id": "ideogram-default",
"nodes": [
{
"type": "CLIPTextEncode",
"offset": [900, 0],
"size": [225, 116],
"widgets_values": [""],
"properties": { "Node name for S&R": "CLIPTextEncode" }
},
{
"type": "ConditioningZeroOut",
"offset": [1180, 0],
"size": [240, 48],
"widgets_values": [],
"properties": { "Node name for S&R": "ConditioningZeroOut" }
},
{
"type": "CFGOverride",
"offset": [1180, 120],
"size": [280, 106],
"widgets_values": [3.0, 0.7, 1.0],
"properties": { "Node name for S&R": "CFGOverride" }
},
{
"type": "DualModelGuider",
"offset": [1500, 120],
"size": [266, 150],
"widgets_values": [7.0],
"properties": { "Node name for S&R": "DualModelGuider" }
},
{
"type": "PromptChain_IdeogramSampler",
"offset": [1840, 360],
"size": [310, 220],
"widgets_values": [4, 0.08],
"properties": { "Node name for S&R": "PromptChain_IdeogramSampler" }
},
{
"type": "SaveImage",
"offset": [2440, 0],
"size": [400, 460],
"widgets_values": ["Ideogram4/%date:yyyy-MM-dd%"],
"properties": {}
},
{
"type": "UNETLoader",
"offset": [820, 200],
"size": [320, 82],
"widgets_values": ["ideogram4_fp8_scaled.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "UNETLoader",
"offset": [820, 320],
"size": [320, 82],
"widgets_values": ["ideogram4_unconditional_fp8_scaled.safetensors", "default"],
"properties": { "Node name for S&R": "UNETLoader" }
},
{
"type": "CLIPLoader",
"offset": [560, 0],
"size": [320, 110],
"widgets_values": ["qwen3vl_8b_fp8_scaled.safetensors", "ideogram4", "default"],
"properties": { "Node name for S&R": "CLIPLoader" }
},
{
"type": "VAELoader",
"offset": [1500, 300],
"size": [246, 58],
"widgets_values": ["flux2-vae.safetensors"],
"properties": { "Node name for S&R": "VAELoader" }
},
{
"type": "RandomNoise",
"offset": [1500, 400],
"size": [260, 82],
"widgets_values": [0, "randomize"],
"properties": { "Node name for S&R": "RandomNoise" }
},
{
"type": "KSamplerSelect",
"offset": [1500, 520],
"size": [240, 58],
"widgets_values": ["euler"],
"properties": { "Node name for S&R": "KSamplerSelect" }
},
{
"type": "Ideogram4Scheduler",
"offset": [1500, 620],
"size": [300, 154],
"widgets_values": [20, 1024, 1024, 0.5, 1.75],
"properties": { "Node name for S&R": "Ideogram4Scheduler" }
},
{
"type": "EmptyFlux2LatentImage",
"offset": [1500, 800],
"size": [290, 106],
"widgets_values": [1024, 1024, 1],
"properties": { "Node name for S&R": "EmptyFlux2LatentImage" }
},
{
"type": "PromptChain_IdeogramCaption",
"offset": [600, 180],
"size": [250, 82],
"widgets_values": ["envelope"],
"properties": { "Node name for S&R": "PromptChain_IdeogramCaption" }
}
],
"connections": [
{ "from_node_idx": 8, "from_slot": 0, "to_node_idx": 0, "to_slot": 0 },
{ "from_node_idx": 14, "from_slot": 0, "to_node_idx": 0, "to_slot": 1 },
{ "from_node_idx": 0, "from_slot": 0, "to_node_idx": 3, "to_slot": 1 },
{ "from_node_idx": 0, "from_slot": 0, "to_node_idx": 1, "to_slot": 0 },
{ "from_node_idx": 1, "from_slot": 0, "to_node_idx": 3, "to_slot": 3 },
{ "from_node_idx": 6, "from_slot": 0, "to_node_idx": 2, "to_slot": 0 },
{ "from_node_idx": 2, "from_slot": 0, "to_node_idx": 3, "to_slot": 0 },
{ "from_node_idx": 7, "from_slot": 0, "to_node_idx": 3, "to_slot": 2 },
{ "from_node_idx": 3, "from_slot": 0, "to_node_idx": 4, "to_slot": 1 },
{ "from_node_idx": 10, "from_slot": 0, "to_node_idx": 4, "to_slot": 0 },
{ "from_node_idx": 11, "from_slot": 0, "to_node_idx": 4, "to_slot": 2 },
{ "from_node_idx": 12, "from_slot": 0, "to_node_idx": 4, "to_slot": 3 },
{ "from_node_idx": 13, "from_slot": 0, "to_node_idx": 4, "to_slot": 4 },
{ "from_node_idx": 9, "from_slot": 0, "to_node_idx": 4, "to_slot": 5 },
{ "from_node_idx": 4, "from_slot": 0, "to_node_idx": 5, "to_slot": 0 }
],
"anchorConnections": [
{ "from_slot": 1, "to_node_idx": 14, "to_slot": 0 }
]
}
+1 -1
View File
@@ -37,7 +37,7 @@ function closeMenu() {
// ── graph traversal ──────────────────────────────────────────────
const KSAMPLER_TYPES = new Set(["KSampler", "KSamplerAdvanced", "PromptChain_RegionalDetailer", "SamplerCustomAdvanced", "UltimateSDUpscale"]);
const KSAMPLER_TYPES = new Set(["KSampler", "KSamplerAdvanced", "PromptChain_RegionalDetailer", "SamplerCustomAdvanced", "UltimateSDUpscale", "PromptChain_IdeogramSampler"]);
const LOAD_IMAGE_TYPES = new Set(["LoadImage", "LoadImageOutput"]);
function _findDownstream(startNode, targetTypes, graph) {
const visited = new Set();
+1
View File
@@ -55,6 +55,7 @@ let focusedGroup = null;
// PromptChain feeding them. Mirrored from main.js — the canonical set.
const KSAMPLER_TYPES = new Set([
"KSampler", "KSamplerAdvanced", "PromptChain_RegionalDetailer", "SamplerCustomAdvanced", "UltimateSDUpscale",
"PromptChain_IdeogramSampler",
]);
// Workflow-level fullscreen state stash. The preview panels are
+1
View File
@@ -39,6 +39,7 @@ api.addEventListener("promptchain_recognition_done", () => {
const KSAMPLER_TYPES = new Set([
"KSampler", "KSamplerAdvanced",
"PromptChain_RegionalDetailer", "SamplerCustomAdvanced", "UltimateSDUpscale",
"PromptChain_IdeogramSampler",
]);
const MODEL_WIDGET_NAMES = ["ckpt_name", "unet_name", "model_name"];
+2
View File
@@ -19,6 +19,7 @@ export const ARCHITECTURES = [
{ id: "wan22", label: "Wan 2.2" }, { id: "ltx", label: "LTX Video" },
{ id: "hunyuan_video", label: "HunyuanVideo" }, { id: "hidream", label: "HiDream" },
{ id: "ernie", label: "ERNIE Image" },
{ id: "ideogram", label: "Ideogram" },
];
export const FAMILIES = {
@@ -35,6 +36,7 @@ export const FAMILIES = {
hunyuan_video: [{ id: "hunyuan_video_15", label: "HunyuanVideo 1.5" }],
hidream: [{ id: "hidream", label: "HiDream-I1" }],
ernie: [{ id: "ernie_base", label: "ERNIE Image" }, { id: "ernie_turbo", label: "ERNIE Image Turbo" }],
ideogram: [{ id: "ideogram4", label: "Ideogram 4" }],
};
// ── supported node types + widgets ─────────────────────────────────
+156 -18
View File
@@ -1411,11 +1411,82 @@
.pcr-viewer-lineage-node.current.svelte-5cciiw {
border-color: #4fc3f7;
background: rgba(79, 195, 247, 0.15);
box-shadow: 0 0 0 1px rgba(79, 195, 247, 0.5), 0 0 10px 1px rgba(79, 195, 247, 0.45);
}
.pcr-viewer-lineage-node.svelte-5cciiw:hover:not(.current) {
background: rgba(255, 255, 255, 0.1);
border-color: #555;
}
/* multi-select for batch delete */
.pcr-viewer-lineage-node.selected.svelte-5cciiw {
border-color: #ffb84d;
box-shadow: 0 0 0 1px rgba(255, 184, 77, 0.6);
}
/* centered over the thumb so it never collides with the corner badges (dot,
layers, fork pip / expander, opbadge) — the .selected ring already frames it */
.pcr-lin-selcheck.svelte-5cciiw {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
line-height: 1;
border-radius: 50%;
background: rgba(255, 184, 77, 0.92);
color: #1a1208;
pointer-events: none;
z-index: 3;
}
.pcr-lin-focuscrumb.svelte-5cciiw {
margin: 0 6px 4px;
padding: 3px 6px;
font-size: 10px;
border-radius: 4px;
border: 1px solid rgba(120, 200, 255, 0.4);
background: rgba(40, 70, 95, 0.5);
color: #bfe3ff;
cursor: pointer;
white-space: nowrap;
}
.pcr-lin-focuscrumb.svelte-5cciiw:hover { background: rgba(50, 90, 120, 0.7); }
.pcr-lin-focusx.svelte-5cciiw { opacity: 0.7; margin-left: 2px; }
.pcr-lin-selbar.svelte-5cciiw {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
margin: 0 6px 4px;
padding: 3px 6px;
font-size: 10px;
border-radius: 4px;
background: rgba(95, 65, 20, 0.5);
color: #ffd9a3;
}
.pcr-lin-seldel.svelte-5cciiw {
padding: 2px 6px;
border-radius: 3px;
border: none;
background: #e0863a;
color: #1a1208;
font-weight: 700;
cursor: pointer;
}
.pcr-lin-seldel.svelte-5cciiw:hover { background: #f09a4d; }
.pcr-lin-selclear.svelte-5cciiw {
padding: 2px 5px;
border-radius: 3px;
border: none;
background: transparent;
color: #ffd9a3;
cursor: pointer;
}
.pcr-lin-selclear.svelte-5cciiw:hover { color: #fff; }
.pcr-viewer-lineage-node.svelte-5cciiw img:where(.svelte-5cciiw) {
width: 100%;
height: 100%;
@@ -1521,29 +1592,36 @@
/* ── subway lanes: the rail grows horizontally into parallel vertical lanes ── */
.pcr-viewer-lineage-visible.lanes.svelte-5cciiw {
width: auto;
max-width: 62vw;
max-width: 45vw;
}
.pcr-lin-lanes.svelte-5cciiw {
position: relative;
/* scroll viewport — fills the rail; the sized tree inside scrolls both axes */
.pcr-lin-treescroll.svelte-5cciiw {
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 22px;
padding: 8px 12px;
overflow: auto;
padding: 8px 12px;
position: relative; /* offset origin for centering the current node (contentXY) */
}
.pcr-lin-lanes.svelte-5cciiw::-webkit-scrollbar { width: 7px; height: 7px; }
.pcr-lin-lanes.svelte-5cciiw::-webkit-scrollbar-track { background: transparent; }
.pcr-lin-lanes.svelte-5cciiw::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
.pcr-lin-treescroll.svelte-5cciiw::-webkit-scrollbar { width: 7px; height: 7px; }
.pcr-lin-treescroll.svelte-5cciiw::-webkit-scrollbar-track { background: transparent; }
.pcr-lin-treescroll.svelte-5cciiw::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
/* both axes scroll here, so kill the default white corner where they meet */
.pcr-lin-lanes.svelte-5cciiw::-webkit-scrollbar-corner { background: transparent; }
.pcr-lin-col.svelte-5cciiw {
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
flex: 0 0 auto;
.pcr-lin-treescroll.svelte-5cciiw::-webkit-scrollbar-corner { background: transparent; }
/* sized to the tree's extent; nodes are absolutely positioned within it and the
SVG connector layer measures geometry relative to this element */
.pcr-lin-tree.svelte-5cciiw {
position: relative;
}
.pcr-lin-treenode.svelte-5cciiw {
position: absolute;
transition: opacity 0.12s ease;
}
/* fade everything off the displayed image's branch so the active lineage reads
at a glance; hovering a faded node brings it back to inspect it */
.pcr-lin-treenode.dimmed.svelte-5cciiw {
opacity: 0.7;
}
.pcr-lin-treenode.dimmed.svelte-5cciiw:hover {
opacity: 1;
}
/* SVG overlay lives inside the scroll content, so it translates with scroll for
free — geometry is only recomputed on expand/fold/resize/img-load. */
@@ -1559,6 +1637,66 @@
stroke-width: 2;
fill: none;
}
/* the displayed image's lineage path — drawn last so it sits over dim edges */
.pcr-lin-connectors.svelte-5cciiw path.active:where(.svelte-5cciiw) {
stroke: #3b9eff;
stroke-width: 3;
}
/* overflow bundle sitting in a tree row (oldest siblings folded away) */
.pcr-lin-treebundle.svelte-5cciiw {
width: 64px;
height: 44px;
}
/* faint blue tie between active-branch thumbs and the bright path connecting them */
.pcr-lin-treenode.svelte-5cciiw:not(.dimmed) .pcr-viewer-lineage-node:where(.svelte-5cciiw):not(.current) {
border-color: rgba(59, 158, 255, 0.45);
}
/* upscale provenance badge — bottom-left, clears the layers badge (top-left) and dot (bottom-right) */
.pcr-lin-opbadge.svelte-5cciiw {
position: absolute;
bottom: 3px;
left: 3px;
padding: 0 3px;
height: 14px;
display: flex;
align-items: center;
font-size: 9px;
font-weight: 700;
line-height: 1;
border-radius: 3px;
background: rgba(20, 20, 20, 0.82);
color: #8fd0ff;
box-shadow: 0 0 0 1px rgba(59, 158, 255, 0.5);
pointer-events: none;
}
/* at-rest "this node hides N branches" marker; swapped for the ⊞ button on hover */
.pcr-lin-forkpip.svelte-5cciiw {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 3px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
line-height: 1;
border-radius: 4px;
background: rgba(20, 20, 20, 0.7);
color: #c0a3ff;
box-shadow: 0 0 0 1px rgba(150, 120, 255, 0.5);
pointer-events: none;
}
.pcr-viewer-lineage-node.svelte-5cciiw:hover .pcr-lin-forkpip:where(.svelte-5cciiw),
.pcr-viewer-lineage-node.svelte-5cciiw:hover .pcr-lin-opbadge:where(.svelte-5cciiw) {
display: none;
}
.pcr-viewer-tip-op.svelte-5cciiw {
color: #8fd0ff;
}
.pcr-lin-expander.svelte-5cciiw {
position: absolute;
top: 2px;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -140,7 +140,7 @@ let _executionWorkflowId = null;
let _lastPreviewUrl = null;
const OUTPUT_NODE_TYPES = new Set(["SaveImage", "PreviewImage"]);
const KSAMPLER_TYPES = new Set(["KSampler", "KSamplerAdvanced", "PromptChain_RegionalDetailer", "SamplerCustomAdvanced", "UltimateSDUpscale"]);
const KSAMPLER_TYPES = new Set(["KSampler", "KSamplerAdvanced", "PromptChain_RegionalDetailer", "SamplerCustomAdvanced", "UltimateSDUpscale", "PromptChain_IdeogramSampler"]);
const IMAGE_LATENT_TYPES = new Set(["IMAGE", "LATENT"]);
const LOAD_IMAGE_TYPES = new Set(["LoadImage"]);
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import json
import logging
from comfy_api.latest import io
logger = logging.getLogger("promptchain.ideogram_caption")
def _envelope(text: str) -> str:
"""Wrap plain text in Ideogram's minimal JSON caption envelope.
Ideogram 4 was trained on structured JSON captions; its docs state the
'Image blocked by safety filter' false-positive rate is HIGHER for non-JSON
prompts. Measured on a benign prompt: plain text blocked 4/5 seeds, this
mechanical envelope (coherent text) blocked 1/5. The remaining ~20% is
cleared by the auto-retry sampler. (A real per-element deconstruction via an
LLM gets to 0/5 — that's the optional upgrade when a local LLM is set up.)
"""
t = (text or "").strip()
return json.dumps(
{"high_level_description": t,
"compositional_deconstruction": {"background": t, "elements": []}},
ensure_ascii=False)
class IdeogramCaptionNode(io.ComfyNode):
"""Turn a PromptChain prompt into an Ideogram-friendly JSON caption.
Sits between PromptChain's `positive` output and CLIPTextEncode in the
Ideogram template. JSON-shaped prompts trip Ideogram's stochastic safety
refusal far less often than plain text. If the user already wrote valid JSON,
it's passed through untouched.
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="PromptChain_IdeogramCaption",
display_name="Prompt Chain Ideogram Caption",
category="promptchain",
inputs=[
io.String.Input("text", default="", force_input=True,
tooltip="Wire to Prompt Chain's 'positive' output."),
io.Combo.Input("mode", options=["envelope", "off"], default="envelope",
tooltip="envelope = wrap plain text in Ideogram's JSON caption "
"(reduces the model's false safety blocks); off = passthrough."),
],
outputs=[
io.String.Output("text"),
],
)
@classmethod
def execute(cls, text: str = "", mode: str = "envelope") -> io.NodeOutput:
s = (text or "").strip()
if mode == "off" or not s:
return io.NodeOutput(text)
# Already valid JSON (user hand-wrote a caption) — leave it alone.
if s.startswith("{") and s.endswith("}"):
try:
json.loads(s)
return io.NodeOutput(text)
except json.JSONDecodeError:
pass
return io.NodeOutput(_envelope(text))
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import logging
import torch
import comfy.sample
import comfy.model_management
import comfy.utils
import latent_preview
from comfy_api.latest import io
logger = logging.getLogger("promptchain.ideogram_sampler")
class IdeogramSamplerNode(io.ComfyNode):
"""Ideogram 4 sampler with automatic retry past the model's stochastic
'Image blocked by safety filter' refusal.
Ideogram 4's open weights emit a near-uniform gray refusal frame on a
fraction of seeds — an officially-acknowledged false-positive (higher for
non-JSON prompts), baked into the weights and not disableable. It's
seed-dependent, so re-rolling the noise seed escapes it. This node samples,
VAE-decodes, and if the decoded image is a near-flat refusal frame (pixel
std below `block_std`), it re-samples with the next seed up to `max_retries`
times. Measured separation: refusal frames ~0.04 std, real images ~0.15-0.33
(0-1 scale), so the default 0.08 sits cleanly between.
Drop-in replacement for SamplerCustomAdvanced + VAEDecode (returns IMAGE).
"""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="PromptChain_IdeogramSampler",
display_name="Prompt Chain Ideogram Sampler (auto-retry)",
category="promptchain",
inputs=[
io.Noise.Input("noise"),
io.Guider.Input("guider"),
io.Sampler.Input("sampler"),
io.Sigmas.Input("sigmas"),
io.Latent.Input("latent_image"),
io.Vae.Input("vae"),
io.Int.Input("max_retries", default=4, min=0, max=16,
tooltip="Extra re-rolls if the model returns its gray "
"'Image blocked by safety filter' frame."),
io.Float.Input("block_std", default=0.08, min=0.0, max=1.0, step=0.005,
tooltip="Pixel-std threshold below which a decoded image is "
"treated as a blocked/refusal frame (0-1 scale)."),
],
outputs=[
io.Image.Output("IMAGE"),
],
)
@classmethod
def execute(cls, noise, guider, sampler, sigmas, latent_image,
vae, max_retries: int = 4, block_std: float = 0.08) -> io.NodeOutput:
base = latent_image
samples_in = comfy.sample.fix_empty_latent_channels(
guider.model_patcher, base["samples"],
base.get("downscale_ratio_spacial", None),
base.get("downscale_ratio_temporal", None))
noise_mask = base.get("noise_mask", None)
batch_inds = base.get("batch_index", None)
base_seed = int(getattr(noise, "seed", 0))
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
last_image = None
for attempt in range(max_retries + 1):
seed = base_seed + attempt # attempt 0 keeps the user's seed
noise_tensor = comfy.sample.prepare_noise(samples_in, seed, batch_inds)
x0_output = {}
callback = latent_preview.prepare_callback(
guider.model_patcher, sigmas.shape[-1] - 1, x0_output)
samples = guider.sample(noise_tensor, samples_in, sampler, sigmas,
denoise_mask=noise_mask, callback=callback,
disable_pbar=disable_pbar, seed=seed)
samples = samples.to(comfy.model_management.intermediate_device())
image = vae.decode(samples)
if len(image.shape) == 5: # video VAE — flatten batches
image = image.reshape(-1, image.shape[-3], image.shape[-2], image.shape[-1])
last_image = image
std = float(image.float().std().item())
blocked = std < block_std
logger.info("[IdeogramSampler] attempt %d/%d seed=%d std=%.4f -> %s",
attempt, max_retries, seed, std,
"BLOCKED (retry)" if blocked else "ok")
if not blocked:
return io.NodeOutput(image)
logger.warning("[IdeogramSampler] still blocked after %d attempts; "
"returning last frame", max_retries + 1)
return io.NodeOutput(last_image)
+495 -72
View File
@@ -6,7 +6,7 @@
import RePoseModal from "./RePoseModal.svelte";
import ConfirmModal from "./sidebar/ConfirmModal.svelte";
import LineageContextMenu from "./LineageContextMenu.svelte";
import { buildRender, ancestry } from "../lib/lineage-lanes.js";
import { buildRender, ancestry, CHILDREN_PER_ROW_CAP, CHILDREN_REVEAL_STEP } from "../lib/lineage-lanes.js";
import { buildFigureRegions, matchRegionByOverlap } from "../lib/region-binding.js";
let {
@@ -270,6 +270,29 @@
return { byHash, childrenOf, roots };
})());
// What operation produced a node, inferred ONLY from data we hold per family node
// (dimensions vs its parent). Upscale is the one transform we can call with high
// confidence here; re-roll/variation/repose would need a per-node op field from the
// server (no seed/prompt on lineage records) — left unlabeled rather than guessed.
// Edits are already surfaced separately via the editDocLayers (▦) badge.
let lineageOps = $derived.by(() => {
const { byHash } = lineageTree;
const ops = new Map();
for (const item of lineageList) {
const p = item?.parent_hash ? byHash.get(item.parent_hash) : null;
if (!p || !item.width || !item.height || !p.width || !p.height) continue;
if (item.width > p.width * 1.2 && item.height > p.height * 1.2) {
const wf = item.width / p.width, hf = item.height / p.height;
// only assert a single multiplier when both axes scaled together; otherwise
// it's a non-uniform grow — show the badge without an overstated factor
const uniform = Math.abs(wf - hf) < 0.05;
ops.set(item.hash, { kind: "upscale", factor: wf, uniform, from: { w: p.width, h: p.height } });
}
}
return ops;
});
const fmtFactor = (f) => (Math.abs(f - Math.round(f)) < 0.05 ? `${Math.round(f)}×` : `${f.toFixed(1)}×`);
let lineageDisplay = $derived((() => {
const asNodes = (list) => list.map(item => ({ kind: "node", item }));
if (lineageList.length < 2) return asNodes(lineageList);
@@ -361,11 +384,83 @@
// so navigating between nodes never re-partitions the lanes, and the displayed
// image is always on column 0 (anchored on the anchor's root→tip spine).
let brokenOut = $state(new Set());
// Per-parent override of how many siblings render individually before the rest
// fold into a "+N" bundle chip; bumped by CHILDREN_REVEAL_STEP on a chip click.
let lineageRevealed = $state(new Map());
let subwayAnchor = $derived(
lineageFocusHash && lineageTree.byHash.has(lineageFocusHash) ? lineageFocusHash : displayedHash
);
let laneRender = $derived(buildRender(lineageTree, subwayAnchor, brokenOut));
let lanesActive = $derived(brokenOut.size > 0 && lineageList.length > 1);
// "Focus this branch": a VIEW-ONLY re-root. buildRender renders only this hash's
// subtree (no parent_hash mutation, no effect on which image is displayed/loaded).
let focusRootHash = $state(null);
let laneRender = $derived(buildRender(lineageTree, subwayAnchor, brokenOut,
{ displayedHash, revealed: lineageRevealed, rootHash: focusRootHash }));
let lanesActive = $derived((brokenOut.size > 0 || !!focusRootHash) && lineageList.length > 1);
// is `desc` the focus root or a descendant of it? (guarded parent_hash climb)
function isInSubtree(desc, rootHash) {
const { byHash } = lineageTree;
const seen = new Set();
let cur = desc;
while (cur && byHash.has(cur) && !seen.has(cur)) {
seen.add(cur);
if (cur === rootHash) return true;
cur = byHash.get(cur).parent_hash;
}
return false;
}
// Drop a stale focus when it leaves the family OR when navigation (arrows / strip /
// timeline) moves the displayed image out of the focused subtree — otherwise the
// current image would have no node in the focus-only tree and nothing would mark
// "you are here". Writes focusRootHash only in the clear branch, so it can't loop.
$effect(() => {
if (!focusRootHash) return;
if (!lineageTree.byHash.has(focusRootHash) || !isInSubtree(displayedHash, focusRootHash)) focusRootHash = null;
});
function focusBranch(hash) {
if (!hash || !lineageTree.byHash.has(hash)) return;
// land on the branch root first if the displayed image is outside the subtree,
// so the invalidation effect doesn't immediately clear the focus we just set.
if (!isInSubtree(displayedHash, hash)) lineageJump(hash);
focusRootHash = hash;
}
function clearFocus() { focusRootHash = null; }
// ── per-family expansion persistence ──
// Remembers which branches the user expanded, keyed by the IMMUTABLE family root
// (a content sha256), so reopening a family restores its tree shape. This is a
// pure UI overlay: it stores ONLY brokenOut/revealed and never writes
// displayedHash or touches how/which image loads — the family you land on is
// always decided by the invoke point, and the materialize effect still guarantees
// the displayed image is shown regardless of what was restored.
const LANE_PREFS_KEY = "pcr.viewer.lanePrefs.v1";
let familyRootHash = $derived(
lineageTree.byHash.has(displayedHash) ? ancestry(lineageTree, displayedHash).root : null
);
let hydratedRoot = null; // plain let: writing it must not re-trigger the hydrate effect
function readLanePrefs() {
try { return JSON.parse(localStorage.getItem(LANE_PREFS_KEY) || "{}") || {}; } catch { return {}; }
}
// hydrate once when we enter a family; restore only branches that still exist
$effect(() => {
const root = familyRootHash;
if (!root || root === hydratedRoot) return;
hydratedRoot = root;
const prefs = readLanePrefs()[root];
brokenOut = new Set((prefs?.brokenOut || []).filter(h => lineageTree.byHash.has(h)));
lineageRevealed = new Map((prefs?.revealed || []).filter(([k]) => lineageTree.byHash.has(k)));
});
// persist after hydration; an empty overlay drops the entry rather than storing {}
$effect(() => {
const root = familyRootHash;
const bo = [...brokenOut], rv = [...lineageRevealed];
if (!root || root !== hydratedRoot) return;
try {
const all = readLanePrefs();
if (bo.length || rv.length) all[root] = { brokenOut: bo, revealed: rv };
else delete all[root];
localStorage.setItem(LANE_PREFS_KEY, JSON.stringify(all));
} catch {}
});
// The child that CONTINUES a node's lane — "expand branches" peels the SIBLINGS,
// never this. Matches buildLane's primary rule (spine-ward child, else oldest).
@@ -398,7 +493,43 @@
}
brokenOut = next;
}
function collapseAllLanes() { brokenOut = new Set(); }
function collapseAllLanes() { brokenOut = new Set(); lineageRevealed = new Map(); focusRootHash = null; }
// ── multi-select (ctrl/⌘/shift-click thumbs) → batch delete ──
let selectedHashes = $state(new Set());
// Selection is scoped to the visible family: drop any selected hash that isn't in
// the current family tree (navigating to another family via arrows/timeline/gallery
// must never leave a stale selection that a later [Del] would silently tombstone).
// Reads selectedHashes but only writes a strictly smaller set, so it can't loop.
$effect(() => {
if (!selectedHashes.size) return;
const { byHash } = lineageTree;
const kept = [...selectedHashes].filter(h => byHash.has(h));
if (kept.length !== selectedHashes.size) selectedHashes = new Set(kept);
});
function toggleSelect(hash) {
const next = new Set(selectedHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash);
selectedHashes = next;
}
function clearSelection() { if (selectedHashes.size) selectedHashes = new Set(); }
// Shared click for every lineage thumb (strip + tree): modifier-click multi-selects;
// a plain click clears any selection and navigates.
function handleNodeClick(e, item) {
if (compareDropdownOpen) {
if (item.hash !== displayedHash) enterCompareWith(item.hash, item.filename || item.hash.slice(0, 8));
return;
}
if (e.ctrlKey || e.metaKey || e.shiftKey) { toggleSelect(item.hash); return; }
clearSelection();
lineageJump(item.hash); exitCompare();
}
// Page more of a folded row's oldest siblings back into individual columns.
function revealMore(parentHash) {
const next = new Map(lineageRevealed);
next.set(parentHash, (next.get(parentHash) ?? CHILDREN_PER_ROW_CAP - 1) + CHILDREN_REVEAL_STEP);
lineageRevealed = next;
}
// the family has at least one fork worth fanning out
let hasBranches = $derived((() => {
for (const kids of lineageTree.childrenOf.values()) if (kids.length > 1) return true;
@@ -422,7 +553,7 @@
// only ever ADDS new roots (never loops).
$effect(() => {
if (!lanesActive) return;
if (laneRender.columns.some(c => c.nodes.some(n => n.hash === displayedHash))) return;
if (laneRender.nodes.some(n => n.hash === displayedHash)) return;
const { byHash, childrenOf } = lineageTree;
const a = ancestry(lineageTree, subwayAnchor);
const toBreak = [];
@@ -451,13 +582,36 @@
// recompute only on expand/fold/resize/thumbnail-load — never on scroll, never on
// a timer (event/observer-driven per CLAUDE.md).
let lanesWrapEl = $state(null);
let treeScrollEl = $state(null);
let connectorGeom = $state([]);
let connectorBox = $state({ w: 0, h: 0 });
// The displayed image's root→self lineage — the active branch. One guarded walk
// yields both the edge keys (`parent>child`, for the blue connector highlight)
// and the node set (for dimming everything off the active branch). Derived (not
// measured) so it re-resolves on navigation without recomputing geometry.
let activePath = $derived.by(() => {
const nodes = new Set(), edges = new Set();
const { byHash } = lineageTree;
const seen = new Set(); // cheap defense against malformed data looping the browser
let cur = displayedHash;
while (cur && byHash.has(cur) && !seen.has(cur)) {
seen.add(cur);
nodes.add(cur);
const p = byHash.get(cur).parent_hash;
if (p && byHash.has(p)) edges.add(`${p}>${cur}`);
cur = p;
}
return { nodes, edges };
});
// split so the highlighted paths paint over the dim ones
let connectorsBase = $derived(connectorGeom.filter(g => !activePath.edges.has(g.key)));
let connectorsActive = $derived(connectorGeom.filter(g => activePath.edges.has(g.key)));
let measureScheduled = false;
// Row pitch for vertically offsetting broken-out columns so children sit one
// generation below their parent. MUST match .pcr-viewer-lineage-node height (64px)
// + .pcr-lin-col gap (22px).
// Tree layout pitch: a row is one generation (node 64px + 22px gap), a column
// one sibling slot (node 64px + 24px gap). Each lineage node is placed at
// left = x*LANE_COL_W, top = depth*LANE_ROW_H.
const LANE_ROW_H = 86;
const LANE_COL_W = 88;
// Layout coords relative to a content root — independent of scroll AND of CSS
// transform, so the same measurement serves the P3 zoom panel unchanged.
function contentXY(el, root) {
@@ -497,8 +651,8 @@
}
$effect(() => {
if (!lanesActive) return;
const cols = laneRender.columns; // read → track expand/fold/family changes
if (cols) scheduleMeasure();
const ns = laneRender.nodes; // read → track expand/fold/family changes
if (ns) scheduleMeasure();
});
$effect(() => {
if (!lanesWrapEl) return;
@@ -506,6 +660,36 @@
ro.observe(lanesWrapEl);
return () => ro.disconnect();
});
// "You are here": after expand/fold/navigation, bring the displayed image into
// view — but only if it has drifted out of (or near the edge of) the viewport, so
// we never yank a node the user can already see. Reads displayedHash + nodes so it
// re-runs on every relevant change; the rAF lets layout settle first (no timer).
function centerCurrentNode() {
const scroller = treeScrollEl, wrap = lanesWrapEl;
if (!scroller || !wrap) return;
const el = wrap.querySelector(`[data-hash="${displayedHash}"]`);
if (!el) return;
const p = contentXY(el, scroller);
// Edge margin clamped per-axis so the "already in view" range stays satisfiable
// even on a narrow/linear panel — otherwise a perfectly centered node reads as
// out-of-view and re-fires scrollTo on every navigation (jitter).
const mX = Math.min(LANE_COL_W, Math.max(8, (scroller.clientWidth - p.w) / 2 - 1));
const mY = Math.min(LANE_COL_W, Math.max(8, (scroller.clientHeight - p.h) / 2 - 1));
const offL = p.x - scroller.scrollLeft, offT = p.y - scroller.scrollTop;
const outH = offL < mX || offL + p.w > scroller.clientWidth - mX;
const outV = offT < mY || offT + p.h > scroller.clientHeight - mY;
if (!outH && !outV) return;
scroller.scrollTo({
left: Math.max(0, p.x + p.w / 2 - scroller.clientWidth / 2),
top: Math.max(0, p.y + p.h / 2 - scroller.clientHeight / 2),
behavior: "smooth",
});
}
$effect(() => {
if (!lanesActive) return;
displayedHash; laneRender.nodes; // track → recenter on navigation/expand
requestAnimationFrame(centerCurrentNode);
});
let compareTargets = $derived(
lineageList.filter(item => item?.hash !== displayedHash)
@@ -638,14 +822,18 @@
console.trace("[PCR][viewer] displayedHash ->", displayedHash, "currentIndex", currentIndex);
});
// hash → gallery entry, so per-thumb url lookups are O(1) instead of scanning the
// whole gallery array on every reactive render (every lane thumb + bundle preview
// hit images.find() before this).
let imageByHash = $derived(new Map((images || []).map(i => [i.hash, i])));
function imageUrl(hash) {
// support direct URLs for images without DB hash (browse preview)
const img = images.find(i => i.hash === hash);
const img = imageByHash.get(hash);
if (img?._directUrl) return img._directUrl;
return apiURL(`/promptchain/image/${hash}`);
}
function thumbUrl(hash) {
const img = images.find(i => i.hash === hash);
const img = imageByHash.get(hash);
if (img?._directUrl) return img._directUrl;
return apiURL(`/promptchain/thumb/${hash}`);
}
@@ -1321,11 +1509,19 @@
if (upscaleModalOpen || inpaintModalOpen || editModalOpen || confirmDeleteOpen) return; // a modal owns the keyboard while open
if (e.key === "Escape") {
if (compareDropdownOpen) { compareDropdownOpen = false; return; }
if (selectedHashes.size) { clearSelection(); return; }
if (historyExpanded) { historyExpanded = false; return; }
if (compareMode) { exitCompare(); return; }
onClose();
return;
}
if ((e.key === "Delete" || e.key === "Backspace") && selectedHashes.size && onDelete) {
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; // let editors keep Backspace
e.preventDefault();
requestDeleteBatch();
return;
}
if ((e.key === "c" || e.key === "C") && !e.ctrlKey && !e.metaKey && hasCompareTargets) {
e.preventDefault();
if (compareMode) exitCompare();
@@ -1443,15 +1639,26 @@
// specific lineage node (right-click menu), which may not be the one on screen.
let deleteTargetHash = $state(null);
// null = single delete; an array = a multi-select batch (drives the modal copy).
let deleteBatch = $state(null);
function requestDelete() {
if (!onDelete || deleting) return;
deleteTargetHash = null;
deleteBatch = null;
confirmDeleteOpen = true;
}
function requestDeleteBatch() {
if (!onDelete || deleting || !selectedHashes.size) return;
deleteBatch = [...selectedHashes];
confirmDeleteOpen = true;
}
function cancelDelete() {
confirmDeleteOpen = false;
deleteTargetHash = null;
deleteBatch = null;
}
// ── lineage strip right-click menu ──
@@ -1506,6 +1713,8 @@
if (!hash) return;
if (action === "expand-branches") {
expandBranches(item);
} else if (action === "focus-branch") {
focusBranch(hash);
} else if (action === "copy-path") {
copyPath(hash);
} else if (action === "edit") {
@@ -1523,9 +1732,52 @@
}
}
async function handleDeleteBatch(hashes) {
deleteBatch = null;
deleting = true;
// Only remove what truly deleted: onDelete throws by contract on failure (file
// locked, non-OK, reattach mismatch). Dropping the whole batch optimistically
// would hide a still-on-disk image until the next refetch — match the single
// delete, which filters only after a successful await.
const gone = new Set();
try {
for (const h of hashes) {
try { await onDelete(h); gone.add(h); } catch (err) { console.error("[PromptChain] batch delete failed for", h, err); }
}
if (gone.size) {
selectedHashes = new Set([...selectedHashes].filter(h => !gone.has(h))); // keep any that failed
const survivorsOrdered = lineageVisible.map(i => i?.hash).filter(h => h && !gone.has(h));
const landHash = (displayedHash && !gone.has(displayedHash)) ? displayedHash : (survivorsOrdered[0] || null);
images = images.filter(i => !gone.has(i.hash));
const survivors = lineageList.filter(item => item?.hash && !gone.has(item.hash));
if (!images.length) {
if (survivors.length) {
const next = survivors.find(s => s.hash === landHash) || survivors[0];
images = survivors;
currentIndex = Math.max(0, survivors.findIndex(s => s.hash === next.hash));
displayedHash = next.hash;
} else {
onClose();
}
} else if (landHash && images.some(i => i.hash === landHash)) {
currentIndex = images.findIndex(i => i.hash === landHash);
displayedHash = landHash;
} else {
const idx = Math.min(currentIndex, images.length - 1);
currentIndex = idx;
displayedHash = images[idx].hash;
}
}
} catch (e) {
console.error("[PromptChain] viewer batch delete failed", e);
}
deleting = false;
}
async function handleDelete() {
confirmDeleteOpen = false;
if (!onDelete || deleting) return;
if (deleteBatch?.length) { await handleDeleteBatch(deleteBatch); return; }
const hash = deleteTargetHash || displayedHash;
deleteTargetHash = null;
// Land on the PREVIOUS member of the same lineage chain (its parent/ancestor)
@@ -1640,33 +1892,29 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pcr-viewer" bind:this={viewerEl} style="transform: translateY({viewerTransformY}px); opacity: {viewerOpacity}">
<!-- left: lineage panel -->
{#snippet lineageNode(item, lane)}
{#snippet lineageNode(item, meta)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pcr-viewer-lineage-node"
data-hash={item.hash}
class:current={item.hash === displayedHash}
class:selected={selectedHashes.has(item.hash)}
class:orphaned={item.orphaned === 1}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash}
class:compare-left={compareMode && item.hash === displayedHash}
class:compare-right={compareMode && item.hash === compareTargetHash}
onclick={() => {
if (compareDropdownOpen) {
if (item.hash !== displayedHash) enterCompareWith(item.hash, item.filename || item.hash.slice(0, 8));
return;
}
lineageJump(item.hash); exitCompare();
}}
onclick={(e) => handleNodeClick(e, item)}
onmouseenter={(e) => showTip(e, item)}
onmouseleave={hideTip}
oncontextmenu={(e) => { hideTip(); openLineageMenu(e, item); }}
>
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" onload={scheduleMeasure} />
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" />
{#if selectedHashes.has(item.hash)}<div class="pcr-lin-selcheck">✓</div>{/if}
<div class="pcr-viewer-lineage-dot"
class:dot-root={item.hash === laneRender.rootHash}
class:dot-current={item.hash === displayedHash}
class:dot-tip={item.hash === lane.nodes[lane.nodes.length - 1].hash && item.hash !== displayedHash}
class:dot-tip={meta?.isTip && item.hash !== displayedHash}
></div>
{#if editDocLayers[item.hash]}
<div class="pcr-viewer-lineage-layers">
@@ -1675,21 +1923,29 @@
</svg>
</div>
{/if}
{#if laneRender.junctionsByNode.get(item.hash)?.hidden.length}
<button class="pcr-lin-expander" title="Break this node's {laneRender.junctionsByNode.get(item.hash).hidden.length} other branch(es) into their own columns"
onclick={(e) => { e.stopPropagation(); expandBranches(item); }}
>⊞{laneRender.junctionsByNode.get(item.hash).hidden.length}</button>
{#if lineageOps.get(item.hash)?.kind === "upscale"}
{@const op = lineageOps.get(item.hash)}
<div class="pcr-lin-opbadge" title="Upscaled {op.uniform ? fmtFactor(op.factor) + ' ' : ''}from {op.from.w}×{op.from.h}">▲{op.uniform ? fmtFactor(op.factor) : ""}</div>
{/if}
{#if brokenOut.has(lane.startHash) && item.hash === lane.startHash}
<button class="pcr-lin-fold" title="Fold this column back into its branch"
onclick={(e) => { e.stopPropagation(); foldLane(lane.startHash); }}
{#if laneRender.junctionsByNode.get(item.hash)?.hidden.length}
{@const n = laneRender.junctionsByNode.get(item.hash).hidden.length}
<!-- at-rest marker that this node hides branches; the hover ⊞ button below acts on it -->
<div class="pcr-lin-forkpip" aria-hidden="true">⋔{n}</div>
<button class="pcr-lin-expander" title="Show this node's {n} other branch(es)"
onclick={(e) => { e.stopPropagation(); expandBranches(item); }}
>⊞{n}</button>
{/if}
{#if brokenOut.has(item.hash)}
<button class="pcr-lin-fold" title="Collapse this branch back in"
onclick={(e) => { e.stopPropagation(); foldLane(item.hash); }}
>⊟</button>
{/if}
</div>
{/snippet}
{#if lineageList.length > 1}
<div class="pcr-viewer-lineage pcr-viewer-lineage-visible" class:lanes={lanesActive}>
<div class="pcr-viewer-lineage pcr-viewer-lineage-visible" class:lanes={lanesActive}
style={lanesActive ? `width: ${laneRender.width * LANE_COL_W + 24}px` : ""}>
<div class="pcr-viewer-lineage-header">
<span class="pcr-lin-count">{lineageCurrentIdx + 1} / {lineageList.length}</span>
{#if hasBranches}
@@ -1701,18 +1957,47 @@
>{lanesActive ? "⊟" : "⊞"}</button>
{/if}
</div>
{#if focusRootHash}
<button class="pcr-lin-focuscrumb" onclick={clearFocus} title="Showing only this branch — click to show the whole family">
⊙ Focused <span class="pcr-lin-focusx">✕</span>
</button>
{/if}
{#if selectedHashes.size}
<div class="pcr-lin-selbar">
{selectedHashes.size} selected
<button class="pcr-lin-seldel" onclick={requestDeleteBatch} title="Delete selected (Del)">Delete</button>
<button class="pcr-lin-selclear" onclick={clearSelection} title="Clear selection (Esc)">✕</button>
</div>
{/if}
{#if lanesActive}
<div class="pcr-lin-lanes" bind:this={lanesWrapEl}>
<svg class="pcr-lin-connectors" width={connectorBox.w} height={connectorBox.h} aria-hidden="true">
{#each connectorGeom as g (g.key)}<path d={g.d} />{/each}
</svg>
{#each laneRender.columns as lane (lane.startHash)}
<div class="pcr-lin-col" style="margin-top: {lane.topRow * LANE_ROW_H}px">
{#each lane.nodes as item (item.hash)}
{@render lineageNode(item, lane)}
{/each}
</div>
{/each}
<div class="pcr-lin-treescroll" bind:this={treeScrollEl}>
<div class="pcr-lin-tree" bind:this={lanesWrapEl}
style="width: {laneRender.width * LANE_COL_W}px; height: {laneRender.height * LANE_ROW_H}px">
<svg class="pcr-lin-connectors" width={connectorBox.w} height={connectorBox.h} aria-hidden="true">
{#each connectorsBase as g (g.key)}<path d={g.d} />{/each}
{#each connectorsActive as g (g.key)}<path class="active" d={g.d} />{/each}
</svg>
{#each laneRender.nodes as node (node.hash)}
<div class="pcr-lin-treenode" class:dimmed={!activePath.nodes.has(node.hash)}
style="left: {node.x * LANE_COL_W}px; top: {node.depth * LANE_ROW_H}px">
{#if node.kind === "bundle"}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<button class="pcr-viewer-lineage-bundle pcr-lin-treebundle" data-hash={node.hash}
title="{node.count} older generation{node.count > 1 ? 's' : ''} — click to show more"
onclick={() => revealMore(node.parent)}>
<div class="pcr-viewer-lineage-bundle-stack">
{#each node.preview as p (p)}
<img src={thumbUrl(p)} alt="" draggable="false" loading="lazy" />
{/each}
</div>
<span class="pcr-viewer-lineage-bundle-count">+{node.count}</span>
</button>
{:else}
{@render lineageNode(node.item, node)}
{/if}
</div>
{/each}
</div>
</div>
{:else}
<div class="pcr-viewer-lineage-strip">
@@ -1727,25 +2012,18 @@
class:ancestor={i < lineageDisplayCurrentIdx}
class:descendant={i > lineageDisplayCurrentIdx}
class:branch={d.branch}
class:selected={selectedHashes.has(item.hash)}
class:orphaned={item.orphaned === 1}
class:compare-candidate={compareDropdownOpen && item.hash !== displayedHash}
class:compare-left={compareMode && item.hash === displayedHash}
class:compare-right={compareMode && item.hash === compareTargetHash}
onclick={() => {
// With the compare dropdown open, the strip doubles as the
// target picker — clicking a thumbnail compares against it
// instead of navigating away.
if (compareDropdownOpen) {
if (item.hash !== displayedHash) enterCompareWith(item.hash, item.filename || item.hash.slice(0, 8));
return;
}
lineageJump(item.hash); exitCompare();
}}
onclick={(e) => handleNodeClick(e, item)}
onmouseenter={(e) => showTip(e, item)}
onmouseleave={hideTip}
oncontextmenu={(e) => { hideTip(); openLineageMenu(e, item); }}
>
<img src={thumbUrl(item.hash)} alt="" draggable="false" loading="lazy" />
{#if selectedHashes.has(item.hash)}<div class="pcr-lin-selcheck">✓</div>{/if}
<div class="pcr-viewer-lineage-dot"
class:dot-root={item.hash === lineageList[0]?.hash}
class:dot-current={i === lineageDisplayCurrentIdx}
@@ -1798,6 +2076,10 @@
<div class="pcr-viewer-tip-name">{hoverTip.item.filename || hoverTip.item.hash?.slice(0, 12)}</div>
{#if hoverTip.item.created_at}<div>{timeAgo(hoverTip.item.created_at)}</div>{/if}
{#if hoverTip.item.width && hoverTip.item.height}<div>{hoverTip.item.width}×{hoverTip.item.height}</div>{/if}
{#if lineageOps.get(hoverTip.item.hash)?.kind === "upscale"}
{@const op = lineageOps.get(hoverTip.item.hash)}
<div class="pcr-viewer-tip-op">▲ upscaled {op.uniform ? fmtFactor(op.factor) + " " : ""}from {op.from.w}×{op.from.h}</div>
{/if}
{#if editDocLayers[hoverTip.item.hash]}<div class="pcr-viewer-tip-layers">▦ {editDocLayers[hoverTip.item.hash]} layers — opens layered in Edit</div>{/if}
</div>
{/if}
@@ -2362,9 +2644,11 @@
<ConfirmModal
open={confirmDeleteOpen}
title="Delete image"
message="Permanently delete this image? This removes the file from disk and can't be undone."
confirmLabel="Delete"
title={deleteBatch?.length ? `Delete ${deleteBatch.length} images` : "Delete image"}
message={deleteBatch?.length
? `Permanently delete these ${deleteBatch.length} selected images? This removes the files from disk and can't be undone.`
: "Permanently delete this image? This removes the file from disk and can't be undone."}
confirmLabel={deleteBatch?.length ? `Delete ${deleteBatch.length}` : "Delete"}
onConfirm={handleDelete}
onCancel={cancelDelete}
/>
@@ -2374,6 +2658,7 @@
x={lineageMenu.x}
y={lineageMenu.y}
canExpand={lineageMenu.item ? canExpand(lineageMenu.item) : false}
canFocus={lineageMenu.item ? (lineageTree.childrenOf.get(lineageMenu.item.hash)?.length > 0 && lineageMenu.item.hash !== focusRootHash) : false}
canEdit={!!onEditSave}
canDelete={!!onDelete}
isLocal={isLocalClient}
@@ -2448,11 +2733,82 @@
.pcr-viewer-lineage-node.current {
border-color: #4fc3f7;
background: rgba(79, 195, 247, 0.15);
box-shadow: 0 0 0 1px rgba(79, 195, 247, 0.5), 0 0 10px 1px rgba(79, 195, 247, 0.45);
}
.pcr-viewer-lineage-node:hover:not(.current) {
background: rgba(255, 255, 255, 0.1);
border-color: #555;
}
/* multi-select for batch delete */
.pcr-viewer-lineage-node.selected {
border-color: #ffb84d;
box-shadow: 0 0 0 1px rgba(255, 184, 77, 0.6);
}
/* centered over the thumb so it never collides with the corner badges (dot,
layers, fork pip / expander, opbadge) — the .selected ring already frames it */
.pcr-lin-selcheck {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
line-height: 1;
border-radius: 50%;
background: rgba(255, 184, 77, 0.92);
color: #1a1208;
pointer-events: none;
z-index: 3;
}
.pcr-lin-focuscrumb {
margin: 0 6px 4px;
padding: 3px 6px;
font-size: 10px;
border-radius: 4px;
border: 1px solid rgba(120, 200, 255, 0.4);
background: rgba(40, 70, 95, 0.5);
color: #bfe3ff;
cursor: pointer;
white-space: nowrap;
}
.pcr-lin-focuscrumb:hover { background: rgba(50, 90, 120, 0.7); }
.pcr-lin-focusx { opacity: 0.7; margin-left: 2px; }
.pcr-lin-selbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
margin: 0 6px 4px;
padding: 3px 6px;
font-size: 10px;
border-radius: 4px;
background: rgba(95, 65, 20, 0.5);
color: #ffd9a3;
}
.pcr-lin-seldel {
padding: 2px 6px;
border-radius: 3px;
border: none;
background: #e0863a;
color: #1a1208;
font-weight: 700;
cursor: pointer;
}
.pcr-lin-seldel:hover { background: #f09a4d; }
.pcr-lin-selclear {
padding: 2px 5px;
border-radius: 3px;
border: none;
background: transparent;
color: #ffd9a3;
cursor: pointer;
}
.pcr-lin-selclear:hover { color: #fff; }
.pcr-viewer-lineage-node img {
width: 100%;
height: 100%;
@@ -2558,29 +2914,36 @@
/* ── subway lanes: the rail grows horizontally into parallel vertical lanes ── */
.pcr-viewer-lineage-visible.lanes {
width: auto;
max-width: 62vw;
max-width: 45vw;
}
.pcr-lin-lanes {
position: relative;
/* scroll viewport — fills the rail; the sized tree inside scrolls both axes */
.pcr-lin-treescroll {
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 22px;
padding: 8px 12px;
overflow: auto;
padding: 8px 12px;
position: relative; /* offset origin for centering the current node (contentXY) */
}
.pcr-lin-lanes::-webkit-scrollbar { width: 7px; height: 7px; }
.pcr-lin-lanes::-webkit-scrollbar-track { background: transparent; }
.pcr-lin-lanes::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
.pcr-lin-treescroll::-webkit-scrollbar { width: 7px; height: 7px; }
.pcr-lin-treescroll::-webkit-scrollbar-track { background: transparent; }
.pcr-lin-treescroll::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
/* both axes scroll here, so kill the default white corner where they meet */
.pcr-lin-lanes::-webkit-scrollbar-corner { background: transparent; }
.pcr-lin-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
flex: 0 0 auto;
.pcr-lin-treescroll::-webkit-scrollbar-corner { background: transparent; }
/* sized to the tree's extent; nodes are absolutely positioned within it and the
SVG connector layer measures geometry relative to this element */
.pcr-lin-tree {
position: relative;
}
.pcr-lin-treenode {
position: absolute;
transition: opacity 0.12s ease;
}
/* fade everything off the displayed image's branch so the active lineage reads
at a glance; hovering a faded node brings it back to inspect it */
.pcr-lin-treenode.dimmed {
opacity: 0.7;
}
.pcr-lin-treenode.dimmed:hover {
opacity: 1;
}
/* SVG overlay lives inside the scroll content, so it translates with scroll for
free — geometry is only recomputed on expand/fold/resize/img-load. */
@@ -2596,6 +2959,66 @@
stroke-width: 2;
fill: none;
}
/* the displayed image's lineage path — drawn last so it sits over dim edges */
.pcr-lin-connectors path.active {
stroke: #3b9eff;
stroke-width: 3;
}
/* overflow bundle sitting in a tree row (oldest siblings folded away) */
.pcr-lin-treebundle {
width: 64px;
height: 44px;
}
/* faint blue tie between active-branch thumbs and the bright path connecting them */
.pcr-lin-treenode:not(.dimmed) .pcr-viewer-lineage-node:not(.current) {
border-color: rgba(59, 158, 255, 0.45);
}
/* upscale provenance badge — bottom-left, clears the layers badge (top-left) and dot (bottom-right) */
.pcr-lin-opbadge {
position: absolute;
bottom: 3px;
left: 3px;
padding: 0 3px;
height: 14px;
display: flex;
align-items: center;
font-size: 9px;
font-weight: 700;
line-height: 1;
border-radius: 3px;
background: rgba(20, 20, 20, 0.82);
color: #8fd0ff;
box-shadow: 0 0 0 1px rgba(59, 158, 255, 0.5);
pointer-events: none;
}
/* at-rest "this node hides N branches" marker; swapped for the ⊞ button on hover */
.pcr-lin-forkpip {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 3px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
line-height: 1;
border-radius: 4px;
background: rgba(20, 20, 20, 0.7);
color: #c0a3ff;
box-shadow: 0 0 0 1px rgba(150, 120, 255, 0.5);
pointer-events: none;
}
.pcr-viewer-lineage-node:hover .pcr-lin-forkpip,
.pcr-viewer-lineage-node:hover .pcr-lin-opbadge {
display: none;
}
.pcr-viewer-tip-op {
color: #8fd0ff;
}
.pcr-lin-expander {
position: absolute;
top: 2px;
@@ -4,6 +4,7 @@
let {
x, y,
canExpand = false,
canFocus = false,
canEdit = false,
canDelete = false,
isLocal = false,
@@ -47,8 +48,11 @@
<div use:portal class="pcr-ctx" bind:this={menuEl} style="left:{pos.left}px;top:{pos.top}px;">
{#if canExpand}
<button class="pcr-ctx-item" onclick={() => act("expand-branches")}>Expand branches</button>
{#if canEdit || canDelete || isLocal}<div class="pcr-ctx-sep"></div>{/if}
{/if}
{#if canFocus}
<button class="pcr-ctx-item" onclick={() => act("focus-branch")}>Focus this branch</button>
{/if}
{#if (canExpand || canFocus) && (canEdit || canDelete || isLocal)}<div class="pcr-ctx-sep"></div>{/if}
{#if canEdit}
<button class="pcr-ctx-item" onclick={() => act("edit")}>Edit</button>
{/if}
+131 -34
View File
@@ -49,54 +49,151 @@ export function buildLane(tree, startHash, spineSet) {
return { startHash, nodes, junctions };
}
// Full render: column 0 anchored on `anchorHash`'s spine, then DFS the broken-out
// lanes (each appears just right of the junction it forks from). Returns:
// columns — lanes in left-to-right DFS order (column 0 first)
// edges — { from: forkHash, to: childRootHash } per broken-out lane,
// consumed as DATA by the SVG connector layer (geometry is
// measured from the DOM; topology is never inferred from it)
// A row stays readable up to this many columns; past it, the OLDEST overflow
// siblings collapse into a single "+N" bundle chip that takes the last column
// slot (so the visible width is bounded). CHILDREN_REVEAL_STEP is how many more
// individual siblings each bundle click pages in.
export const CHILDREN_PER_ROW_CAP = 5;
export const CHILDREN_REVEAL_STEP = 4;
const DEFAULT_REVEAL = CHILDREN_PER_ROW_CAP - 1; // reals shown alongside the chip
// Full render. The breakout walk (col0 spine + DFS through broken-out junctions)
// decides WHICH nodes are shown and collects the per-node junction record; then a
// tidy top-down tree positions them, collapsing wide sibling rows into bundles.
//
// opts.displayedHash — the image currently on screen; its whole root→self chain
// is protected from bundling so the active branch is always visible.
// opts.revealed — Map<parentHash, realsBudget>: how many individual children
// to show for a parent before bundling the rest (paged up by bundle clicks).
//
// Returns:
// nodes — for every placed item: real nodes { kind:"node", hash, item,
// x, depth, isTip } and bundle chips { kind:"bundle", hash,
// parent, hidden:[…], preview:[…], count, x, depth }.
// depth = generation (row); x = column (may be fractional),
// assigned post-order so each leaf takes the next slot and each
// parent centers over its children. Absolutely positioned
// (left = x*COL_W, top = depth*ROW_H).
// edges — { from, to } parent→child among PLACED nodes (a bundle's
// `to` is its synthetic id), consumed as DATA by the SVG
// connector layer (geometry is measured from the DOM).
// junctionsByNode — Map<atHash, { shown:[], hidden:[] }> powering the ⊞ expander
// badge and the context-menu "Expand branches" gate
// rootHash — column 0's first node (for the dot-root badge)
// topRow is the column's vertical offset measured in ROWS from the top of the
// whole lane area. A broken-out child column starts one generation BELOW its fork
// (topRow = parentTopRow + forkRowInParent + 1), i.e. the SAME row as the sibling
// that stayed inline in the parent column. So a parent sits a row above all its
// children, children of children another row down — a proper top-down hierarchy
// instead of every column top-aligned (which made parents look like peers).
export function buildRender(tree, anchorHash, brokenOut) {
// badge and the context-menu "Expand branches" gate.
// rootHash — the family root (for the dot-root badge).
// width, height — extent in columns / rows, for sizing the scroll content.
export function buildRender(tree, anchorHash, brokenOut, opts = {}) {
const { byHash, childrenOf } = tree;
const { displayedHash, revealed = new Map(), rootHash: rootOverride } = opts;
const a = ancestry(tree, anchorHash);
const col0 = buildLane(tree, a.root, a.set);
const columns = [];
const edges = [];
// "Focus this branch" renders only the subtree from rootOverride down; otherwise
// the lane starts at the true family root.
const laneStart = (rootOverride && byHash.has(rootOverride)) ? rootOverride : a.root;
const col0 = buildLane(tree, laneStart, a.set);
const shown = new Set();
const started = new Set();
const junctionsByNode = new Map();
const visit = (lane, topRow) => {
lane.topRow = topRow;
columns.push(lane);
// spine edges: every consecutive pair in a column is a parent→primary-child
// link, drawn as a straight vertical connector (same column = same x).
for (let i = 0; i + 1 < lane.nodes.length; i++) {
edges.push({ from: lane.nodes[i].hash, to: lane.nodes[i + 1].hash });
}
const visit = (lane) => {
for (const n of lane.nodes) shown.add(n.hash);
for (const j of lane.junctions) {
const forkRow = lane.nodes.findIndex(n => n.hash === j.atHash);
let rec = junctionsByNode.get(j.atHash);
if (!rec) { rec = { shown: [], hidden: [] }; junctionsByNode.set(j.atHash, rec); }
for (const childRoot of j.childRoots) {
if (brokenOut.has(childRoot)) {
rec.shown.push(childRoot);
if (!started.has(childRoot)) {
started.add(childRoot);
edges.push({ from: j.atHash, to: childRoot });
visit(buildLane(tree, childRoot), topRow + forkRow + 1);
}
if (!started.has(childRoot)) { started.add(childRoot); visit(buildLane(tree, childRoot)); }
} else {
rec.hidden.push(childRoot);
}
}
}
};
visit(col0, 0);
return { columns, edges, junctionsByNode, rootHash: col0.nodes.length ? col0.nodes[0].hash : null };
visit(col0);
// The root→displayed chain is protected so the active branch never bundles away.
const activePath = ancestry(tree, displayedHash || anchorHash).set;
const childrenShown = (h) => (childrenOf.get(h) || []).filter(k => shown.has(k.hash));
const hasShownSubtree = (h) => childrenShown(h).length > 0;
// Tidy layout. childrenShown is in created_at order (oldest→newest). A parent
// with more than the cap collapses its oldest non-protected leaf siblings into
// one bundle chip (placed leftmost, where the old generations would sit); the
// remaining reals keep their column each. Sibling subtrees occupy disjoint
// column ranges, so same-row nodes never overlap and a parent always sits above
// its own lineage.
const xOf = new Map();
const depthOf = new Map();
const placed = new Set();
const foldedAway = new Set(); // children collapsed into a chip — never placed as reals
const bundles = [];
let cursor = 0;
const place = (h, depth) => {
depthOf.set(h, depth);
placed.add(h);
const kids = childrenShown(h);
if (!kids.length) { xOf.set(h, cursor++); return; }
let bundleKids = [];
let reals = kids;
if (kids.length > CHILDREN_PER_ROW_CAP) {
// Candidates to fold: leaf siblings that aren't on the active path.
const optional = kids.filter(k => !activePath.has(k.hash) && !hasShownSubtree(k.hash));
const budget = revealed.get(h) ?? DEFAULT_REVEAL;
const forced = kids.length - optional.length; // protected reals
const keepOptional = Math.max(0, budget - forced); // newest optional kept as reals
const keep = new Set(optional.slice(Math.max(0, optional.length - keepOptional)).map(k => k.hash));
bundleKids = optional.filter(k => !keep.has(k.hash)); // the oldest leftover
if (bundleKids.length <= 1) bundleKids = []; // a 1-item chip saves nothing
const folded = new Set(bundleKids.map(k => k.hash));
reals = kids.filter(k => !folded.has(k.hash));
}
const xs = [];
if (bundleKids.length) {
const id = `bundle:${h}`;
depthOf.set(id, depth + 1);
xOf.set(id, cursor++);
xs.push(xOf.get(id));
bundles.push({
id, parent: h, depth: depth + 1,
hidden: bundleKids.map(k => k.hash),
preview: bundleKids.slice(0, 2).map(k => k.hash),
count: bundleKids.length,
});
for (const k of bundleKids) foldedAway.add(k.hash);
}
for (const k of reals) { place(k.hash, depth + 1); xs.push(xOf.get(k.hash)); }
xOf.set(h, (xs[0] + xs[xs.length - 1]) / 2);
};
const rootHash = col0.nodes.length ? col0.nodes[0].hash : null;
if (rootHash && shown.has(rootHash)) place(rootHash, 0);
// Stragglers (defensive): shown nodes the spine walk never reached. Folded
// children are intentionally unplaced (they live in a chip) — skip them here or
// they'd be re-placed as duplicate real nodes at row 0, defeating the bundle.
for (const h of shown) if (!placed.has(h) && !foldedAway.has(h)) place(h, 0);
// Edges derive from parent_hash over the placed reals (reproducing every spine
// and broken-out junction edge), plus one parent→chip edge per bundle. Folded
// children aren't placed, so no connector dangles at a missing node.
const edges = [];
for (const h of placed) {
if (h === rootHash) continue;
const p = byHash.get(h)?.parent_hash;
if (p && placed.has(p)) edges.push({ from: p, to: h });
}
for (const b of bundles) edges.push({ from: b.parent, to: b.id });
let maxX = 0, maxDepth = 0;
const nodes = [];
const note = (x, depth) => { if (x > maxX) maxX = x; if (depth > maxDepth) maxDepth = depth; };
for (const h of placed) {
const x = xOf.get(h), depth = depthOf.get(h);
note(x, depth);
nodes.push({ kind: "node", hash: h, item: byHash.get(h), x, depth, isTip: childrenShown(h).length === 0 });
}
for (const b of bundles) {
const x = xOf.get(b.id);
note(x, b.depth);
nodes.push({ kind: "bundle", hash: b.id, parent: b.parent, hidden: b.hidden, preview: b.preview, count: b.count, x, depth: b.depth });
}
return { nodes, edges, junctionsByNode, rootHash, width: maxX + 1, height: maxDepth + 1 };
}