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:
+3
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1557
-1069
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user