- 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.
401 lines
18 KiB
JavaScript
401 lines
18 KiB
JavaScript
// 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));
|
||
});
|
||
}
|