Files
chrmc40 e4b970d2b6 Publish snapshot: image editor suite, Krea 2 templates, sidebar fixes
- Image editor: layers with masks/blend modes, lightweight groups, Camera Raw
  scoped to the selected layer, full-frame i2i, Copy Merged, Photoshop keyboard
  shortcuts, and a large batch of Photoshop-parity bug fixes.
- LoadImage right-click "Open in PromptChain editor" bridge (edit -> lineage
  child re-pointed onto the node).
- Krea 2 Turbo/Realism templates and editor modal integration; remove the
  krea2 Text-to-Image 3D Regional template.
- ControlNet depth + 3D-pose: wire Poser true-depth straight into Apply
  ControlNet on pose paths.
- Sidebar: drag-to-LoadImage now uploads to the input root so the widget value
  is a valid bare filename; "Copy as path" emits native Windows backslashes.
- Contributor/relicensing docs and packaging tweaks.
2026-06-28 11:58:31 -07:00

401 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Viewer bridge — lazy-loads the Svelte image viewer and manages its lifecycle.
// Supports hash URI deep-linking: #pcr/view?h={hash}&w={workflowId}
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { fetchWorkflowImages, invalidateCache } from "./history.js";
import { ensureSvelteCSS, createModuleLoader } from "./lazy-load.js";
import { prepareUpscale, buildUpscaleWorkflow } from "./upscale-from-image.js";
import { upscaleImageInBackground, enlargeImageInBackground } from "./upscale-background.js";
import { prepareInpaint, runInpaint, saveInpaintResult, uploadInputImage, inpaintRegion, sourcePromptText, sourcePromptTextFull, liveWorkflowPrompt } from "./inpaint-background.js";
import { prepareI2I, runI2IInBackground } from "./i2i-background.js";
import { prepareEdit, saveEditedImage } from "./edit-image.js";
import { fetchReposeCaps } from "./repose-from-image.js";
import { runReposeInBackground } from "./repose-background.js";
import { runExtendInBackground } from "./extend-background.js";
import { runSmoothInBackground } from "./smooth-background.js";
import { smoothNodesAvailable } from "./smooth-from-image.js";
import { mountDetachedPoser } from "../pose-studio.js";
import { showNodePackInstallModal } from "./model-bridge.js";
import { loadCodeMirror, createEditor } from "./editor.js";
import { activateTagAutocomplete } from "./autocomplete.js";
// "Extend video": which I2V template to continue with. A continuation is always
// image->video, so it uses an I2V recipe regardless of how the source was made.
// Pick the family whose models are installed (GGUF vs safetensors); the quant
// resolver in buildExtendGraph then maps the template's baked variant to the
// exact file on disk.
async function pickI2VTemplate() {
try {
const res = await api.fetchApi("/promptchain/models/list");
const models = res.ok ? ((await res.json())?.models || []) : [];
const names = models.map((m) => (m.filename || "").toLowerCase());
if (names.some((n) => n.endsWith(".gguf") && n.includes("i2v") && (n.includes("a14b") || n.includes("wan2.2")))) return "wan22-i2v-gguf";
if (names.some((n) => /wan2\.2_i2v_.*(fp8_scaled|fp16)\.safetensors/.test(n))) return "wan22-i2v";
} catch { /* fall through to default */ }
return "wan22-i2v-gguf"; // the resolver remaps to whatever quant is actually installed
}
async function fetchTemplateJson(id) {
try {
const res = await api.fetchApi(`/promptchain/templates/${encodeURIComponent(id)}`);
if (!res.ok) return null;
const data = await res.json();
return data?.template || data;
} catch { return null; }
}
// Lightning (4-step) renders through the "-fast" template, which bakes the
// LightX2V distill LoRAs. Picking it without those files on disk would build an
// empty LoraLoaderModelOnly and render garbage with no error (the same failure
// that rejected sf-video-test). Gate the option on the LoRAs actually being
// installed. Fail-open on any probe error — server-side validation still
// catches a genuinely missing file.
async function lightningLorasInstalled(fastTemplate) {
try {
if (!fastTemplate?.nodes) return true;
const infoRes = await api.fetchApi("/object_info/LoraLoaderModelOnly");
if (!infoRes.ok) return true;
const info = await infoRes.json();
const installed = info?.LoraLoaderModelOnly?.input?.required?.lora_name?.[0] || [];
const installedSet = new Set(installed.map((n) => String(n).toLowerCase()));
const needed = fastTemplate.nodes
.filter((n) => (n.type || n.comfyClass) === "LoraLoaderModelOnly")
.map((n) => String(n.widgets_values?.[0] || "").toLowerCase())
.filter(Boolean);
if (!needed.length) return true;
return needed.every((f) => installedSet.has(f));
} catch { return true; }
}
// Per-recipe sampler defaults read from the SELECTED template's baked widgets —
// NOT a config, because config presets only describe the normal recipe and are
// wrong for the 4-step lightning one. KSamplerAdvanced widget order:
// add_noise, noise_seed, control_after_generate, steps, cfg, sampler_name,
// scheduler, start_at_step, end_at_step, return_with_leftover_noise.
function extractRecipeDefaults(template) {
if (!template?.nodes) return null;
const find = (t) => template.nodes.find((n) => (n.type || n.comfyClass) === t);
const all = (t) => template.nodes.filter((n) => (n.type || n.comfyClass) === t);
const ks = all("KSamplerAdvanced");
const high = ks.find((n) => (n.widgets_values || [])[0] === "enable") || ks[0];
const hv = high?.widgets_values || [];
const ms = find("ModelSamplingSD3");
return {
steps: Number(hv[3]) || 20,
cfg: hv[4] != null ? Number(hv[4]) : 3.5,
sampler: hv[5] || "euler",
scheduler: hv[6] || "simple",
shift: Number((ms?.widgets_values || [])[0]) || 5,
};
}
async function prepareExtend(hash) {
if (!hash) return null;
const res = await api.fetchApi(`/promptchain/extend/prepare?hash=${encodeURIComponent(hash)}`);
if (!res.ok) return null;
const d = await res.json();
if (!d?.last_frame) return null;
const q = new URLSearchParams({ filename: d.last_frame, subfolder: "", type: "input" });
const templateId = await pickI2VTemplate();
const [normalTpl, fastTpl] = await Promise.all([
fetchTemplateJson(templateId),
fetchTemplateJson(`${templateId}-fast`),
]);
return {
lastFrameFilename: d.last_frame,
lastFrameUrl: api.apiURL(`/view?${q}`),
sourceRef: d.source_ref || "",
fps: d.fps || 16,
frameCount: d.frame_count || 0,
width: d.width || 0,
height: d.height || 0,
templateId, // vanilla base; confirmExtend appends "-fast" for the Lightning recipe
lightningAvailable: await lightningLorasInstalled(fastTpl),
// Slider seeds: clip dims follow the SOURCE (drops the 640×640 hardcode).
// Length defaults to the source's DURATION in native-16fps frames (snapped to
// Wan's 4n+1) — so a smoothed 161f@32fps clip (5s) asks for ~81, not 161
// (which would be a too-long 10s render). fps default is the i2v native (16).
defaults: {
// Snap dims to /16 (Wan's latent grid) and clamp to a trained ceiling, so a
// foreign import's odd 1080p dims can't crash the patchify reshape.
width: Math.min(1280, Math.round((d.width || 640) / 16) * 16),
height: Math.min(1280, Math.round((d.height || 640) / 16) * 16),
length: (() => {
const dur = (d.frame_count || 81) / (d.fps || 16);
const native = Math.round(dur * 16);
// Ceiling 161 (~10s): length is ONE continuation clip, never the whole
// (possibly long / already-extended) source duration — else it OOMs.
return Math.min(161, Math.max(5, Math.round((native - 1) / 4) * 4 + 1));
})(),
fps: d.fps || 16,
},
recipeDefaults: {
normal: extractRecipeDefaults(normalTpl),
lightning: extractRecipeDefaults(fastTpl),
},
};
}
// "Smooth": offer to install the RIFE pack (Frame-Interpolation) on first use,
// then re-register node defs so RIFE VFI becomes available without a full reload.
function installFrameInterpPack() {
return new Promise((resolve) => {
let settled = false;
const settle = (v) => { if (!settled) { settled = true; resolve(v); } };
showNodePackInstallModal(
"FrameInterpolation",
async () => {
try { const defs = await app.getNodeDefs(); await app.registerNodesFromDefs(defs); } catch {}
settle(true);
},
() => settle(false),
);
});
}
// The reference box must show the prompt that actually made THIS image.
// The embedded prompt is authoritative whenever it still carries $region
// blocks — those never survive grafted re-renders, so their presence means
// it IS the original source. Only then fall back to the live node, and only
// when the ACTIVE workflow is the image's own: the longest-prompt scan would
// otherwise read whatever tab happens to be open (temp graft tabs included),
// and the live node can drift after the render (seen garbled once).
function resolveReferencePrompt(apiPrompt, viewerWorkflowId) {
const embedded = sourcePromptTextFull(apiPrompt);
if (/\$\w+\s*\{/.test(embedded)) return embedded;
const activeId = (app.rootGraph ?? app.graph)?.id || null;
const live = viewerWorkflowId && activeId === viewerWorkflowId ? liveWorkflowPrompt() : "";
return live || embedded;
}
// Mount the PromptChain CM6 editor (tag highlighting + autocomplete) into a
// modal's container. Same path the node uses — kept out of the viewer's Svelte
// bundle because editor.js imports scripts/api.js, unresolvable at build time.
// Returns the EditorView (caller destroys it on close).
//
// getModelInfo: () => {hash, architecture} | null — modal editors have no
// graph node, so the context menu's Prompts submenu and the @prompt
// autocomplete read model identity through these hooks instead (the modal
// resolves it live: picked engine model, else the image's source model).
async function mountPromptEditor(container, initialValue, onChange, getModelInfo) {
await loadCodeMirror();
const view = createEditor(container, initialValue || "", onChange, null, [], null, null);
if (view) {
if (getModelInfo) {
const resolve = () => { try { return getModelInfo() || null; } catch { return null; } };
// Node stand-in for the context menu (editor.js reads container._pcrNode).
// inputs/outputs/properties keep the node-walking helpers on their
// empty-graph paths; no .graph keeps fullscreen/network items off.
container._pcrNode = { _pcrGetModelInfo: resolve, inputs: [], outputs: [], properties: {} };
view._pcrGetModelInfo = resolve;
}
try { activateTagAutocomplete(window.PromptChainCM, view); }
catch (e) { console.error("[PromptChain] inpaint autocomplete activation failed:", e); }
}
return view;
}
// Inline pack install for an inpaint condition: open the shared install modal and
// resolve true on success (after re-registering node defs), false on cancel.
function installInpaintPack(injectable) {
return new Promise((resolve) => {
let settled = false;
const settle = (v) => { if (!settled) { settled = true; resolve(v); } };
showNodePackInstallModal(
injectable,
async () => {
try { const defs = await app.getNodeDefs(); await app.registerNodesFromDefs(defs); } catch {}
settle(true);
},
() => settle(false),
);
});
}
const ensureCSS = ensureSvelteCSS;
const loadModule = createModuleLoader(() => import("./svelte/promptchain-viewer.js"));
function clearHash() {
history.replaceState(null, "", location.pathname + location.search);
}
// Default delete for DB-tracked images (gallery/deep-link opens pass no
// onDelete of their own). Mirrors the gallery's right-click → Delete: remove
// the file, detach the workflow record, and broadcast so open galleries and
// the sidebar drop the entry.
async function deleteTrackedImage(images, workflowId, hash) {
// Delete the FILE by content hash. The server resolves the real location via
// resolve_image_path (output / input / temp / cached / reattached), so a
// wrong-scope guess can't leave it on disk — the old path hardcoded "output"
// and silently failed to delete input/temp-sourced images. Throw on failure
// so the viewer's handleDelete surfaces it instead of dropping the entry.
const resp = await api.fetchApi("/promptchain/image-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hash }),
});
let info = null;
try { info = await resp.json(); } catch {}
if (!resp.ok || !info?.deleted) {
throw new Error(info?.reason || `delete failed (${resp.status})`);
}
if (workflowId) {
await api.fetchApi(`/promptchain/workflow/${encodeURIComponent(workflowId)}/clear`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hashes: [hash] }),
});
invalidateCache(workflowId);
}
window.dispatchEvent(new CustomEvent("promptchain:file-deleted", {
detail: { scope: info.scope || "output", paths: info.path ? [info.path] : [] },
}));
}
export async function openViewer(images, startIndex = 0, workflowId = "", onDelete = null, opts = {}) {
ensureCSS();
const svelte = await loadModule();
const container = document.createElement("div");
document.body.appendChild(container);
svelte.openImageViewer(container, {
images,
startIndex,
workflowId,
autoEdit: !!opts.autoEdit,
apiURL: (path) => api.apiURL(path),
fetchApi: (path, opts) => api.fetchApi(path, opts),
onClose: () => {
svelte.closeImageViewer();
clearHash();
},
onDelete: onDelete || ((hash) => deleteTrackedImage(images, workflowId, hash)),
onUpscalePrepare: async (entry) => {
const prepared = await prepareUpscale(entry);
// Same prompt surfaces the inpaint modal gets: editable prefill (char
// blocks stripped) + the workflow source as reference.
if (prepared?.caps?.graftable && prepared.data?.prompt) {
prepared.caps.prefillPrompt = sourcePromptText(prepared.data.prompt);
prepared.caps.referencePrompt = resolveReferencePrompt(prepared.data.prompt, workflowId);
}
return prepared;
},
onUpscale: (prepared, options) => buildUpscaleWorkflow(prepared, options),
onUpscaleBackground: (prepared, options) => upscaleImageInBackground(prepared, options),
onUpscaleEnlarge: (inputRef, targetW, targetH, engine) => enlargeImageInBackground(inputRef, targetW, targetH, engine),
onInpaintPrepare: async (entry) => {
const prepared = await prepareInpaint(entry);
if (prepared?.caps && prepared.data?.prompt) {
prepared.caps.referencePrompt = resolveReferencePrompt(prepared.data.prompt, workflowId);
}
return prepared;
},
onInpaintRun: (prepared, options) => runInpaint(prepared, options),
onI2iPrepare: async (entry) => {
const prepared = await prepareI2I(entry);
if (prepared?.caps && prepared.data?.prompt) {
prepared.caps.referencePrompt = resolveReferencePrompt(prepared.data.prompt, workflowId);
}
return prepared;
},
onI2iRun: (prepared, options) => runI2IInBackground(prepared, options),
onInpaintSave: (doneState, prefix) => saveInpaintResult(doneState, prefix),
onInpaintUploadReference: (file) => uploadInputImage(file),
onInpaintInstallPack: (injectable) => installInpaintPack(injectable),
onMountPromptEditor: mountPromptEditor,
onEditPrepare: (entry) => prepareEdit(entry),
onEditSave: async (prepared, blob, prefix) => {
const entry = await saveEditedImage(prepared, blob, prefix);
// Opened from a node (e.g. LoadImage) that wants the result wired back.
if (entry && typeof opts.onAfterEditSave === "function") {
try { await opts.onAfterEditSave(entry); }
catch (e) { console.error("[PromptChain] onAfterEditSave failed", e); }
}
return entry;
},
onInpaintRegion: (data, args) => inpaintRegion(data, args),
onReposeCaps: () => fetchReposeCaps(),
onReposeRun: (opts) => runReposeInBackground(opts),
onMountPoser: (el, opts) => mountDetachedPoser(el, opts),
onExtendPrepare: (hash) => prepareExtend(hash),
onExtendRun: (opts) => runExtendInBackground(opts),
onSmoothCheck: () => smoothNodesAvailable(),
onSmoothInstall: () => installFrameInterpPack(),
onSmoothPrepare: (hash) => prepareExtend(hash), // reuse: stages the source video + fps
onSmoothRun: (opts) => runSmoothInBackground(opts),
});
}
// Restore a sidebar/browse-opened image (s=scope, p=path) — these have no DB
// hash, so rebuild the same _directUrl entry the sidebar constructs.
async function restoreBrowseImage(scope, path) {
const meta = await api.fetchApi(
`/promptchain/browse/meta?scope=${encodeURIComponent(scope)}&path=${encodeURIComponent(path)}`
);
if (!meta.ok) return;
const url = api.apiURL(`/promptchain/browse/preview?scope=${encodeURIComponent(scope)}&path=${encodeURIComponent(path)}`);
const images = [{
hash: path,
filename: path.split("/").pop(),
subfolder: "",
_directUrl: url,
_browseScope: scope,
_browsePath: path,
}];
await openViewer(images, 0, "", async () => {
await api.fetchApi("/promptchain/browse/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, paths: [path] }),
});
window.dispatchEvent(new CustomEvent("promptchain:file-deleted", {
detail: { scope, paths: [path] },
}));
});
}
// Restore viewer from hash URI
async function tryRestoreFromHash() {
const hash = location.hash;
if (!hash.startsWith("#pcr/view")) return;
const params = new URLSearchParams(hash.slice(hash.indexOf("?") + 1));
const browsePath = params.get("p");
if (browsePath) {
await restoreBrowseImage(params.get("s") || "output", browsePath);
return;
}
const imageHash = params.get("h");
const workflowId = params.get("w");
if (!imageHash) return;
if (workflowId) {
const images = await fetchWorkflowImages(workflowId);
const startIndex = images.findIndex(img => img.hash === imageHash);
if (startIndex >= 0) {
await openViewer(images, startIndex, workflowId);
return;
}
// workflow list gone or the image detached from it — fall through and
// restore the linked image itself rather than an unrelated list
}
// h-only link (sidebar opens carry no workflow id) — restore the single
// image; the lineage strip supplies family navigation from there.
const resp = await api.fetchApi(`/promptchain/image-meta/${encodeURIComponent(imageHash)}`);
if (!resp.ok) return;
const meta = await resp.json();
if (!meta?.hash) return;
await openViewer([{ hash: meta.hash, filename: meta.filename || "", subfolder: meta.subfolder || "" }], 0, "");
}
// Run on initial load + listen for hash changes (pasted URLs, shared links)
export function restoreFromHash() {
tryRestoreFromHash().catch(e => console.error("[PromptChain] hash restore failed:", e));
window.addEventListener("hashchange", () => {
tryRestoreFromHash().catch(e => console.error("[PromptChain] hash restore failed:", e));
});
}