diff --git a/CHANGELOG.md b/CHANGELOG.md index 76c8d39a..3a143102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # Changelog +## [Unreleased] + +## [3.4.0] - 2026-07-24 + +### Added +- **Easy Gen** (the simplified single-scene UI) reaches near feature-parity with the Cutting + Room for the workflows it's meant for: + - Shortcut/transition JSON library **import & export** in Settings ▸ Shortcuts (merge or + replace) — Easy Gen previously had no way to load a shortcut/transition library at all. + - The full **Engine settings** panel (Studio refinement/adjustments/sampler algorithm, + Chain Sampler continuity/timing/guidance/decode/experimental, Best-FaceID identity + transfer) — restored via the same shared frontend module the Cutting Room uses. + - A **Log** button (ComfyUI backend log, same as the Cutting Room's). + - An explicit **Save** button, and a **Gallery** picker to reuse already-uploaded media as + the i2v anchor without re-uploading — with a one-click **continuity identity pin** + button right on each thumbnail, mirroring the Cutting Room's Media Bin. + - An **Export…** button to download the project as a `.funpack_project.json` file (e.g. to + move a project off a rental GPU box) — the backend route already existed but had no UI. + +### Changed +- **Loop temporal style now works alongside guides.** Scenes carrying appended guide frames + (carry-i2v-guides, mid-scene guide, JoyAI memory, custom guide stacks) no longer disable + the loop roll: only the content region rolls while the pinned guide tail (and the audio + memory tail) stays canonical, so guides keep informing every position of the cycle without + ever being rendered into it. The i2v anchor was always supported - it rides the denoise + mask, which rolls in step with the latent. +- **Easy Gen's Studio always runs in Prompt-only mode.** Easy Gen has no rating UI, so every + Engine setting that's a no-op without a trained refinement key/rated history (refinement + key, value guidance, steer mode, reference injection, embed/score/output guidance, taste + retrieval, DynaShift, sampler-panel velocity bias/rescue) is hidden there and enforced at + the pipeline level, not just the UI — every Easy Gen generate call forces Studio's `mode` + to "Prompt only" and the rating-gated Chain Sampler knobs off via node overrides, so a + project configured with these on in the Cutting Room still generates plainly from Easy + Gen. A note points to the Cutting Room or the ComfyUI graph for the full learned refiner. +- The "expose to editor" eye buttons in Models & Pipeline are hidden under Easy Gen — there's + no per-scene inspector for an exposed control to ever surface in, so they were inert + clutter that could look actionable and silently do nothing. + +### Fixed +- The shortcut autocomplete menu could open off-screen below the prompt field (Easy Gen's + prompt box sits in a footer bar at the bottom of the viewport) with no way to see or click + a suggestion. Now flips above the field when there isn't room below. +- A node's **bypass** toggle could silently do nothing: when the pass-through mapping was + ambiguous, or the bypassed node's own matching input wasn't wired to anything, the failure + was only ever recorded in a report field no frontend code read or displayed — the node + stayed fully active with no visible error. Generation now blocks with a clear reason + instead of a silent no-op. +- Easy Gen could silently revert Models & Pipeline edits (bypass, exposed widgets, LoRA + settings): any later project save sent a stale in-memory copy of the models config back to + the server, clobbering whatever was just changed there independently. +- Easy Gen's preview restarted playback (ignoring a pause) on every background health poll + or prompt keystroke, since each one rebuilt the video element from scratch. +- A "fetching process...aborted by the user agent" unhandled-rejection error banner could + appear when switching between Gallery/generated media in Easy Gen. +- Downloaded generation results always suggested "result.mp4" as the filename regardless of + the actual render (no `Content-Disposition` on the `/result` route) — the underlying file + was already uniquely named per run and already lived in ComfyUI's temp dir, just wasn't + surfaced to the browser's save dialog. +- Easy Gen's Generation settings showed a "Seed" field always reading "1" and looking fixed. + It was bound to the Project's legacy `seed` field, which nothing in generation has ever + read (the real seed resolution only reads `sampler_inputs.seed`, already correctly random + when unset) — removed the dead field in favor of the working one under Engine ▸ Timing & + Seed, which already says so. + ## [3.3.0] - 2026-07-15 ### Added diff --git a/README.md b/README.md index e68fcbea..71fc8afb 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,20 @@ ## What it looks like -FunPack 3.x ships the **Cutting Room** - a full non-linear video editor living in your browser, inside ComfyUI. A real timeline with per-scene prompts, ratings and audio lanes, a program monitor, a media bin, and one-click generation driving FunPack Studio + the Scene Chain Sampler - no graph wiring required. +Opening `/funpack/` inside ComfyUI lands you on the **Hub** - a small picker between FunPack's two browser UIs, plus at-a-glance version/update status. -![FunPack Cutting Room - timeline, preview monitor, media bin, and scene inspector](docs/images/cutting-room.png) +![FunPack Hub - pick a UI, see version and update status](docs/images/hub.png) + +### Two UIs, one engine + +Both UIs drive the exact same generation engine (FunPack Studio + the LTXAV Scene Chain Sampler) and the exact same project files - a project opened in one opens unchanged in the other. Pick based on how much control you want in front of you: + +- **Cutting Room** - the full multi-scene non-linear editor: a real timeline with per-scene prompts, ratings and audio lanes, a program monitor, a media bin, and every Engine/Studio knob a click away. This is where rating-driven learning (refinement keys, value guidance, taste steering) lives. +- **Easy Gen** - one prompt, one Generate button, one big preview. Built for quick single-scene (or simple multi-scene via prompt splits) generations without the full NLE around it - advanced settings are still there, just tucked behind an "Advanced settings" link, and rating-dependent controls are hidden entirely since there's no rating UI to feed them (Studio always runs in a plain Prompt-only mode there). + +| Cutting Room - timeline, preview monitor, media bin, and scene inspector | Easy Gen - one prompt, one Generate button, one big preview | +| --- | --- | +| ![FunPack Cutting Room - timeline, preview monitor, media bin, and scene inspector](docs/images/cutting-room.png) | ![FunPack Easy Gen - single prompt box, Generate button, and preview pane](docs/images/easy-gen.png) | | The Composer - global prompt, shortcuts, split markers, `$variables` | Engine Settings - Studio + Chain Sampler knobs without touching a graph | | --- | --- | @@ -56,6 +67,13 @@ Montage** trailer builder, a **Temp files** browser, **reconnect-after-reload** generations, and a Blackwell (sm_120) guide-scene crash fix — see the [CHANGELOG](CHANGELOG.md) for the full list. +**3.4.0** brings **Easy Gen** to near feature-parity with the Cutting Room for the workflows +it targets: shortcut/transition library import/export, the full Engine settings panel +(including Best-FaceID), a Gallery picker with a one-click continuity pin, Save/Export buttons, +and several bugfixes (a bypass toggle that could silently do nothing, a state-sync bug that +could revert Models & Pipeline edits, and more) — see the [CHANGELOG](CHANGELOG.md) for the +full list. + See [`docs/MovieEditor.md`](docs/MovieEditor.md) for complete Cutting Room documentation. ## Installation diff --git a/__init__.py b/__init__.py index c3741389..3c55cb3b 100644 --- a/__init__.py +++ b/__init__.py @@ -26,10 +26,18 @@ if __package__: from . import batch_training # noqa: F401 registers /funpack/batch/* routes except Exception as _e: print(f"[FunPack] batch_training routes unavailable: {_e}") + try: + from . import hub # noqa: F401 registers /funpack/ (the UI picker) + except Exception as _e: + print(f"[FunPack] Hub route unavailable: {_e}") try: from .movie_editor import server as _movie_editor_server # noqa: F401 registers /funpack/movie/* routes except Exception as _e: print(f"[FunPack] Movie Editor routes unavailable: {_e}") + try: + from . import easy_gen # noqa: F401 registers /funpack/easy/* routes + except Exception as _e: + print(f"[FunPack] Easy Gen routes unavailable: {_e}") else: # Standalone tests may not have the full ComfyUI/CUDA runtime loaded. from conditioning import ( diff --git a/conditioning.py b/conditioning.py index 9d2baf29..1a77e40a 100644 --- a/conditioning.py +++ b/conditioning.py @@ -6484,10 +6484,10 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): "default": "", "tooltip": "Optional user feedback describing what was specifically wrong with the previous output (e.g. 'he was supposed to hold her hand, not her head'). Has highest priority in the advisor system prompt.", }), - "temporal_style": (["natural", "auto", "accelerate", "decelerate", "loop", "freeze", "pulse"], { + "temporal_style": (["natural", "auto", "accelerate", "decelerate", "loop", "freeze", "pulse", "rapid_start", "rapid_end", "rapid_start_end"], { "default": "natural", "label": "Temporal Style", - "tooltip": "Controls how the model perceives motion timing via frame_rate RoPE manipulation. natural=no change, auto=per-scene director picks motion energy from each scene's prompt (needs the Scene Chain Sampler), accelerate=faster motion, decelerate=heavier motion, loop=circular temporal coords, freeze=highly compressed time, pulse=repeated ease-down motion holds per scene (needs the Scene Chain Sampler).", + "tooltip": "Controls how the model perceives motion timing via frame_rate RoPE manipulation. natural=no change, auto=per-scene director picks motion energy from each scene's prompt (needs the Scene Chain Sampler), accelerate=faster motion, decelerate=heavier motion, loop=circular temporal coords, freeze=highly compressed time, pulse=repeated ease-down motion holds per scene (needs the Scene Chain Sampler), rapid_start=speeds up just the beginning of each scene then settles to natural, rapid_end=natural then speeds up just the ending, rapid_start_end=speeds up both ends with natural motion in the middle.", }), "split_by_transitions": ("BOOLEAN", { "default": False, @@ -13355,6 +13355,24 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): out.append([cond, meta] if isinstance(entry, list) else (cond, meta)) return out + def _v2_apply_rapid_temporal(self, conditioning_list, temporal_style): + """temporal_style in (rapid_start, rapid_end, rapid_start_end): tag each scene + entry so the Scene Chain Sampler installs a rapid-edge ease temporal wrapper + per scene (mirrors _v2_apply_pulse_temporal).""" + style = str(temporal_style or "natural").strip().lower() + if style not in ("rapid_start", "rapid_end", "rapid_start_end"): + return conditioning_list + out = [] + for entry in conditioning_list or []: + if not (isinstance(entry, (list, tuple)) and len(entry) >= 2 and isinstance(entry[1], dict)): + out.append(entry) + continue + cond, meta = entry[0], dict(entry[1]) + meta["funpack_temporal_mode"] = style + meta["funpack_temporal_label"] = style + out.append([cond, meta] if isinstance(entry, list) else (cond, meta)) + return out + def _v2_apply_auto_temporal(self, conditioning_list, temporal_style, fallback_text=""): """temporal_style="auto": bake a per-scene frame_rate multiplier into each conditioning entry's metadata so the Scene Chain Sampler can apply per-scene motion @@ -13452,6 +13470,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): _v2_apply_scene_refinement_keys); scenes without bound keys keep the project key.""" mode = str(steer_mode or "relative").lower() out = self._v2_apply_pulse_temporal(conditioning_list, temporal_style) + out = self._v2_apply_rapid_temporal(out, temporal_style) out = self._v2_apply_auto_temporal(out, temporal_style, fallback_text=temporal_fallback_text) out = self._v2_apply_bounded_attention(out, clip, fallback_text=temporal_fallback_text, encode_cache=encode_cache) if mode in ("relative", "both"): diff --git a/detailing.py b/detailing.py new file mode 100644 index 00000000..fe01765a --- /dev/null +++ b/detailing.py @@ -0,0 +1,505 @@ +"""Segmented detailing: ADetailer-style region refine for the Scene Chain sampler. + +After a scene finishes denoising, CLIPSeg (text-prompted segmentation, the same +model family face-swap stacks use for occlusion masking) locates user-named +regions ("hands", "feet", ...) on a few decoded keyframes. The matched region is +cut out of the video latent as a spatiotemporal tube, pushed through Lightricks' +own trained LatentUpsampler (2x spatial, latent space - the official two-stage +pipeline's stage-2 model), re-noised to the official stage-2 re-entry sigma and +re-denoised for the tail schedule, then downscaled back to its original latent +size and pasted through the feathered CLIPSeg soft mask. Final resolution never +changes: the upsample exists only so the model can resolve structure (fingers, +edges) at a higher working resolution before the crop returns to the grid. + +Boundary law: this is a visual-behavior op, so it lives on the Chain Sampler. +Audio is never modified - the refine pass carries the scene's audio stream so +joint attention sees a legal AV latent, but its audio output is discarded. + +No-op guarantee: empty targets, no upsampler, or no detection above threshold +returns the input latent object untouched (bit-identical run). +""" + +import math + +import torch +import torch.nn.functional as F + +# Official LTX-2.3 two-stage distilled workflow, stage-2 ManualSigmas: re-noise the +# upsampled latent to 0.85 and run the tail of the 8-step distilled schedule. +# Lightricks designed this re-entry point to SHARPEN already-correct structure at +# higher resolution, not to REPAIR a malformed region — our use case (a mangled +# hand) needs more genuine freedom to redraw than their use case (a clean shot +# that just needs finer detail). 0.85 is the built-in default (matches the +# official recipe / prior behavior exactly), but it is a real knob +# (detail_denoise on the sampler) precisely because the right value depends on +# how wrong the source region already is: too high and the region drifts from +# its surroundings; too low and the tail just smooths the upsampled +# interpolation back down without adding anything (upscaled-looking, not +# detailed - the failure mode this constant existed to describe from day one). +DEFAULT_RENOISE_SIGMA = 0.85 +# Shape of the tail preserved as ratios of the entry sigma, not fixed values, so +# raising/lowering the entry point scales the whole tail schedule consistently +# instead of only moving its first step. +_STAGE2_RATIOS = (1.0, 0.725 / 0.85, 0.421875 / 0.85, 0.0) + + +def stage2_sigmas(renoise_sigma=DEFAULT_RENOISE_SIGMA): + """The re-denoise tail schedule for a given re-entry sigma. + + stage2_sigmas(0.85) reproduces the exact official STAGE2_SIGMAS values. + """ + r = max(0.0, min(1.0, float(renoise_sigma))) + return tuple(r * ratio for ratio in _STAGE2_RATIOS) + + +# Kept for anyone importing the old name directly (tests, prior callers) - identical +# to stage2_sigmas() at the default re-entry point. +STAGE2_SIGMAS = stage2_sigmas(DEFAULT_RENOISE_SIGMA) + +# CLIPSeg heat above this (post-sigmoid) counts as "the named thing is here". +DEFAULT_THRESHOLD = 0.35 + +# Default ceiling on tube area, as a fraction of the frame - purely a COST guardrail +# (cost ~= 4x area fraction x tail steps, so a big tube starts to rival a second full +# render) exposed as detail_max_area so the user decides the cost/quality trade-off, +# not this constant. Never silently override what the user asked to detail. +MAX_TUBE_AREA = 0.35 + +# Latent cells of context padding around the thresholded region, and the minimum +# tube edge. Padding gives the refine pass surrounding context to blend against; +# the minimum keeps the crop a legal latent for conv stacks and attention. +TUBE_PAD = 2 +MIN_TUBE_EDGE = 4 + +# Keyframes decoded per scene for detection. Single latent frames decode cheaply +# through the causal VAE (treated as stills) - enough for a temporal-union tube. +DETECTION_FRAMES = 3 + +# Cond keys that tie a scene's conditioning to the full-frame latent layout +# (i2v anchors / guide keyframes). The crop is its own small latent with no +# appended guide frames, so these must not travel into the refine pass. +_LAYOUT_COND_KEYS = ("keyframe_idxs", "guiding_latent", "concat_latent_image", "concat_mask") + +_CLIPSEG = None # (processor, model), lazy - loaded on first enabled run only + + +def _log(debug, msg): + if debug: + print(f"[FunPackDetail] {msg}") + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +# Official Lightricks stage-2 spatial upscaler — the exact file the two-stage +# distilled workflows use. ~1 GB, fetched once into models/latent_upscale_models +# when nothing suitable is installed. +DEFAULT_UPSAMPLER_REPO = "Lightricks/LTX-2.3" +DEFAULT_UPSAMPLER_FILE = "ltx-2.3-spatial-upscaler-x2-1.1.safetensors" + + +def resolve_upsampler_name(model_name=None): + """Turn the node's detail_upsampler widget value into a loadable filename. + + Explicit filename -> returned as-is. "auto" (and the legacy "None" default) + -> prefer an installed spatial upscaler (highest version wins), else any + installed latent upscale model, else download the official file from HF. + Raises with a user-actionable message when nothing can be obtained — callers + surface it, they must NOT silently skip. + """ + import os + + import folder_paths + + name = str(model_name or "").strip() + if name and name not in ("None", "auto"): + return name + files = folder_paths.get_filename_list("latent_upscale_models") + if DEFAULT_UPSAMPLER_FILE in files: + return DEFAULT_UPSAMPLER_FILE + spatial = [f for f in files if "spatial" in f.lower() and "upscal" in f.lower()] + if spatial: + # Highest embedded version wins (…-1.1 over …-1.0); plain lexicographic would + # rank "ltx2_3_…" above "ltx-2.3-…-1.1" ('2' sorts after '-'). + import re + return max(spatial, key=lambda f: ([int(n) for n in re.findall(r"\d+", f)], f)) + if files: + return files[0] + try: + from huggingface_hub import hf_hub_download + except ImportError as exc: + raise RuntimeError( + "no model in models/latent_upscale_models and huggingface_hub is not " + f"installed — pip install huggingface_hub, or download {DEFAULT_UPSAMPLER_FILE} " + f"from {DEFAULT_UPSAMPLER_REPO} into that folder manually") from exc + dest_dir = folder_paths.get_folder_paths("latent_upscale_models")[0] + os.makedirs(dest_dir, exist_ok=True) + print(f"[FunPackDetail] no latent upscale model installed — downloading " + f"{DEFAULT_UPSAMPLER_FILE} (~1 GB) from {DEFAULT_UPSAMPLER_REPO} into {dest_dir} …") + try: + hf_hub_download(repo_id=DEFAULT_UPSAMPLER_REPO, filename=DEFAULT_UPSAMPLER_FILE, + local_dir=dest_dir) + except Exception as exc: + raise RuntimeError( + f"auto-download of {DEFAULT_UPSAMPLER_FILE} failed ({exc}) — if the repo is " + "gated, accept the license on huggingface.co and set HF_TOKEN, or place the " + f"file in {dest_dir} manually") from exc + print(f"[FunPackDetail] download complete: {DEFAULT_UPSAMPLER_FILE}") + return DEFAULT_UPSAMPLER_FILE + + +def load_latent_upsampler(model_name): + """Load Lightricks' LatentUpsampler from models/latent_upscale_models. + + Mirrors the LTX branch of comfy's LatentUpscaleModelLoader (safe_load + + config from safetensors metadata); the architecture itself ships in comfy + core (comfy.ldm.lightricks.latent_upsampler), so there is nothing to port. + """ + import json + + import comfy.model_management + import comfy.utils + import folder_paths + from comfy.ldm.lightricks.latent_upsampler import LatentUpsampler + + model_path = folder_paths.get_full_path_or_raise("latent_upscale_models", model_name) + sd, metadata = comfy.utils.load_torch_file(model_path, safe_load=True, return_metadata=True) + if "post_upsample_res_blocks.0.conv2.bias" not in sd: + raise ValueError( + f"{model_name} is not an LTX latent upsampler (expected post_upsample_res_blocks keys).") + config = json.loads(metadata["config"]) + model = LatentUpsampler.from_config(config).to( + dtype=comfy.model_management.vae_dtype(allowed_dtypes=[torch.bfloat16, torch.float32])) + model.load_state_dict(sd) + model.eval() + return model + + +def _get_clipseg(): + """CLIPSeg processor+model, loaded once and kept on CPU. + + CPU on purpose: ~150M params, a handful of 352px frames per scene - the cost + is milliseconds against a video denoise, and it never competes with the + diffusion model for VRAM. Loaded straight from transformers (which comfy + already ships); no GPL face-swap code involved. + """ + global _CLIPSEG + if _CLIPSEG is None: + from transformers import CLIPSegForImageSegmentation, CLIPSegProcessor + + name = "CIDAS/clipseg-rd64-refined" + processor = CLIPSegProcessor.from_pretrained(name) + model = CLIPSegForImageSegmentation.from_pretrained(name) + model.eval() + _CLIPSEG = (processor, model) + return _CLIPSEG + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + +def parse_targets(detail_targets): + """Comma-separated user text -> clean prompt list ('' -> []).""" + if not detail_targets: + return [] + return [t.strip() for t in str(detail_targets).split(",") if t.strip()] + + +def _decode_detection_frames(vae, video, count, debug=False): + """Decode `count` evenly spaced single latent frames to [H, W, C] float images. + + Each latent frame is decoded alone as [B, C, 1, h, w]: the causal VAE treats + a length-1 clip as a still, which is exactly good enough for segmentation and + avoids decoding the whole scene just to find a region. + """ + frames = [] + f = int(video.shape[2]) + idxs = sorted({min(f - 1, round(i * (f - 1) / max(1, count - 1))) for i in range(max(1, count))}) + for i in idxs: + try: + decoded = vae.decode(video[:, :, i:i + 1]) + except Exception as exc: + _log(debug, f"keyframe {i} decode failed ({exc}); skipping") + continue + img = decoded + if img.dim() == 5: # [B, T, H, W, C] + img = img[0, 0] + elif img.dim() == 4: # [B, H, W, C] + img = img[0] + frames.append((i, img.detach().float().clamp(0.0, 1.0).cpu())) + return frames + + +def _clipseg_heat(frames, prompts, debug=False): + """Max-over-frames, max-over-prompts CLIPSeg heat map. + + Returns [H, W] in [0, 1] at the first frame's pixel size, or None when + nothing was decodable. Max (not mean) across frames on purpose: the tube + must cover the region wherever it appears during the scene. + """ + import numpy as np + from PIL import Image + + if not frames or not prompts: + return None + processor, model = _get_clipseg() + h, w = frames[0][1].shape[0], frames[0][1].shape[1] + heat = None + with torch.no_grad(): + for _, img in frames: + pil = Image.fromarray((img.numpy() * 255.0).astype(np.uint8)) + # padding=True is mandatory once len(prompts) > 1: CLIPSeg's tokenizer + # otherwise returns one variable-length id list per prompt and torch can't + # stack them into a batch ("excessive nesting... type list where int is + # expected"). truncation=True guards the rare very-long user-typed target. + inputs = processor( + text=list(prompts), images=[pil] * len(prompts), return_tensors="pt", + padding=True, truncation=True) + logits = model(**inputs).logits # [P, 352, 352] (or [352, 352] for one prompt) + if logits.dim() == 2: + logits = logits[None] + frame_heat = torch.sigmoid(logits).amax(dim=0)[None, None] # union over prompts + frame_heat = F.interpolate(frame_heat, size=(h, w), mode="bilinear", align_corners=False)[0, 0] + heat = frame_heat if heat is None else torch.maximum(heat, frame_heat) + return heat + + +def find_tube(heat, latent_h, latent_w, threshold=DEFAULT_THRESHOLD, + area_cap=MAX_TUBE_AREA, debug=False, diag=None): + """Threshold pixel-space heat into a latent-space tube box + soft paste mask. + + Returns (y0, y1, x0, x1, mask) in latent coords - mask is [1, 1, 1, th, tw] + matching the tube, feathered from the CLIPSeg heat itself so the paste + follows the actual silhouette instead of the rectangle. None when nothing + crossed the threshold or the tube breached the area cap. + + `diag`, when given a dict, is filled with why a None came back (max_heat + always; reason + area on a miss) so a caller can report something more + useful than "nothing found" - CLIPSeg's raw sigmoid score for a real, + correctly-named region is often well under a naive 0.5, so a silent miss + is indistinguishable from "the threshold is simply too high" without it. + """ + if heat is None: + return None + # Heat -> latent grid first; box math happens where the crop happens. + lat_heat = F.interpolate(heat[None, None], size=(latent_h, latent_w), + mode="bilinear", align_corners=False)[0, 0] + max_heat = float(lat_heat.max()) + if diag is not None: + diag["max_heat"] = max_heat + hot = lat_heat >= threshold + if not bool(hot.any()): + _log(debug, f"no region above threshold {threshold} (max heat seen: {max_heat:.3f})") + if diag is not None: + diag["reason"] = "below_threshold" + return None + ys, xs = torch.where(hot) + y0 = max(0, int(ys.min()) - TUBE_PAD) + y1 = min(latent_h, int(ys.max()) + 1 + TUBE_PAD) + x0 = max(0, int(xs.min()) - TUBE_PAD) + x1 = min(latent_w, int(xs.max()) + 1 + TUBE_PAD) + # Enforce a minimum edge (grow symmetrically, clamped to the frame). + while (y1 - y0) < min(MIN_TUBE_EDGE, latent_h): + y0, y1 = max(0, y0 - 1), min(latent_h, y1 + 1) + while (x1 - x0) < min(MIN_TUBE_EDGE, latent_w): + x0, x1 = max(0, x0 - 1), min(latent_w, x1 + 1) + area = (y1 - y0) * (x1 - x0) / float(latent_h * latent_w) + if area > area_cap: + _log(debug, f"tube area {area:.0%} exceeds cap {area_cap:.0%}; refusing " + "(a region that large is a re-render, not a detail pass)") + if diag is not None: + diag["reason"] = "area_cap" + diag["area"] = area + return None + # Soft mask: the heat inside the tube, floored at 0 outside the threshold and + # lightly blurred so the seam never lands on a hard edge. + m = lat_heat[y0:y1, x0:x1].clone() + m = ((m - threshold) / max(1e-6, 1.0 - threshold)).clamp(0.0, 1.0) + m = F.avg_pool2d(m[None, None], kernel_size=3, stride=1, padding=1)[0, 0] + return y0, y1, x0, x1, m[None, None, None] + + +# --------------------------------------------------------------------------- +# Refine +# --------------------------------------------------------------------------- + +def _strip_layout_conds(conds): + """Copy conditioning minus full-frame layout keys (anchors/guides). + + The crop refine is its own small denoise: text still applies, but keyframe + indices and guide latents describe positions in the full-frame latent and + would fight (or crash) the crop-sized pass. + """ + out = [] + for entry in conds or []: + if isinstance(entry, (list, tuple)) and len(entry) == 2 and isinstance(entry[1], dict): + meta = {k: v for k, v in entry[1].items() if k not in _LAYOUT_COND_KEYS} + out.append([entry[0], meta]) + else: + out.append(entry) + return out + + +def _per_channel_stats(vae): + return getattr(getattr(vae, "first_stage_model", None), "per_channel_statistics", None) + + +def _run_upsampler(upsampler, video_crop, vae, debug=False): + """un_normalize -> LatentUpsampler(2x spatial) -> normalize, on the crop. + + Same recipe as comfy's LTXVLatentUpsampler node: the model was trained on + raw (denormalized) VAE latents, so the trip through per_channel_statistics + is mandatory, not cosmetic. + """ + import comfy.model_management + + device = comfy.model_management.get_torch_device() + model_dtype = next(upsampler.parameters()).dtype + in_dtype, in_device = video_crop.dtype, video_crop.device + stats = _per_channel_stats(vae) + try: + upsampler.to(device) + x = video_crop.to(device=device, dtype=model_dtype) + if stats is not None: + x = stats.un_normalize(x) + with torch.no_grad(): + x = upsampler(x) + if stats is not None: + x = stats.normalize(x) + finally: + upsampler.cpu() + return x.to(device=in_device, dtype=in_dtype) + + +def _downscale_to(video, h, w): + """Area-downscale [B, C, F, H, W] spatially to (h, w); frames independent.""" + b, c, f, hh, ww = video.shape + x = video.permute(0, 2, 1, 3, 4).reshape(b * f, c, hh, ww) + x = F.interpolate(x.float(), size=(h, w), mode="area") + return x.reshape(b, f, c, h, w).permute(0, 2, 1, 3, 4).to(dtype=video.dtype) + + +def detail_refine_scene(chain, model, vae, sampler, positive, negative, latent, + detail_targets, upsampler, seed, cfg, + threshold=DEFAULT_THRESHOLD, strength=1.0, area_cap=MAX_TUBE_AREA, + renoise_sigma=DEFAULT_RENOISE_SIGMA, mode="repair", debug=False): + """Run the segmented-detail pass on one finished scene latent. + + `mode`: + - "sharpen": crop -> upsampler forward -> downscale -> paste. NO video-model + calls at all - the trained upsampler net (small ResBlock/PixelShuffle CNN, + not naive interpolation) is itself doing the work, so this is close to + free next to a diffusion step. It can genuinely sharpen a region that's + blurry/under-resolved but already correctly shaped. It CANNOT fix wrong + structure (an extra finger stays an extra finger) - a super-res net adds + detail consistent with its input, it doesn't invent new correct content. + - "repair" (default): the above PLUS a `renoise_sigma`-entry, 3-step + re-denoise through the actual video model - genuinely expensive (3 extra + forwards on the crop) because only the generative model itself can decide + the region should look structurally different, not just sharper. + + Returns (latent, note): the refined latent dict plus a run_mechanisms note. + Two different "nothing happened" shapes, both same-object no-ops: + - (latent, None): the pass is effectively OFF (no targets, no upsampler, + strength 0) - not worth a note on every scene. + - (latent, "segmented_detail(no match: ...)"): the pass RAN and found + nothing - always reported, with the max CLIPSeg score seen, because a + miss is otherwise indistinguishable from "nothing to detail here" when + it's actually just the threshold being wrong for this content. + `area_cap` is a COST guardrail, not a content judgment: the user named the + target, so a region crossing it is reported as a skip with the actual size + (never silently reinterpreted as "not found") and area_cap itself is a + user-facing knob (detail_max_area) - raise it, don't route around it here. + `chain` is the FunPackLTXAVSceneChainSampler instance (reuses _latent_tensors + and _sample_chunk so the refine denoise goes through the exact same path as + the scene it is polishing). + """ + targets = parse_targets(detail_targets) + if not targets or upsampler is None or strength <= 0: + return latent, None + + tensors = chain._latent_tensors(latent) + video = tensors[0] + if video.dim() != 5: + _log(debug, f"unexpected video latent rank {video.dim()}; skipping") + return latent, None + b, c, f, lat_h, lat_w = video.shape + target_str = ", ".join(targets) + import comfy.nested_tensor + + # 1) Detect on a few decoded keyframes. + frames = _decode_detection_frames(vae, video, DETECTION_FRAMES, debug=debug) + heat = _clipseg_heat(frames, targets, debug=debug) + if heat is None: + return latent, f"segmented_detail(no match: '{target_str}' - no keyframe decoded)" + diag = {} + tube = find_tube(heat, lat_h, lat_w, threshold=threshold, area_cap=area_cap, + debug=debug, diag=diag) + if tube is None: + if diag.get("reason") == "area_cap": + note = (f"segmented_detail(no match: '{target_str}' region covers " + f"{diag['area']:.0%} of frame > {area_cap:.0%} cap - raise " + "detail_max_area if you want it detailed at this size anyway)") + else: + note = (f"segmented_detail(no match: '{target_str}', max CLIPSeg score " + f"{diag.get('max_heat', 0.0):.2f} < threshold {threshold:.2f} - " + "try a lower detail_threshold or a different target word)") + return latent, note + y0, y1, x0, x1, mask = tube + _log(debug, f"tube y[{y0}:{y1}] x[{x0}:{x1}] of {lat_h}x{lat_w} for {targets}") + + # 2) Crop -> 2x latent upsample (Lightricks stage-2 model). + crop = video[:, :, :, y0:y1, x0:x1] + crop_up = _run_upsampler(upsampler, crop, vae, debug=debug) + + if mode == "sharpen": + # No re-denoise, no video-model forward at all: crop_up IS the result. The + # trained upsampler is itself a learned super-resolution net (ResBlocks + + # PixelShuffle), not naive interpolation, so it genuinely hallucinates + # plausible fine detail consistent with the crop's EXISTING structure - + # that's exactly the "cheap, on-the-fly sharpen" the feature was first + # pitched as. What it cannot do: invent correct structure the crop never + # had (an extra finger stays an extra finger, just a sharper one) - that + # needs the model to actually reconsider the region, which is "repair". + refined_crop = crop_up + cost_note = "sharpen: upsampler-only, no diffusion" + else: + # 3) Re-denoise the crop over a stage-2 tail shaped like the official two- + # stage workflow's, but re-entering at `renoise_sigma` (default 0.85, their + # exact value) rather than a hardcoded one: how much genuine freedom the + # model needs to fix this crop depends on how wrong it already is, which + # only the person looking at the result can judge. CONST noise_scaling + # (sigma*noise + (1-sigma)*x) re-noises the clean crop to that sigma + # automatically. The scene's audio stream rides along so joint attention + # sees a legal AV latent, but its refined output is discarded below - audio + # is protected by construction, not by masking. This is the expensive path + # (3 extra video-model forwards on the crop): actually changing WRONG + # structure requires the generative model to redo the region, no shortcut. + crop_latent = {"samples": crop_up} + if len(tensors) > 1: + crop_latent["samples"] = comfy.nested_tensor.NestedTensor( + [crop_up] + [t.detach().clone() for t in tensors[1:]]) + tail_sigmas = torch.tensor(stage2_sigmas(renoise_sigma), dtype=torch.float32) + refined = chain._sample_chunk( + model, sampler, tail_sigmas, int(seed) + 7777, cfg, + _strip_layout_conds(positive), _strip_layout_conds(negative), crop_latent) + refined_crop = chain._latent_tensors(refined)[0] + cost_note = f"repair: renoise={renoise_sigma:.2f}" + + # 4) Back to grid size, feathered paste through the CLIPSeg silhouette. + refined_down = _downscale_to(refined_crop, y1 - y0, x1 - x0) + m = (mask.to(device=video.device, dtype=video.dtype) * float(strength)).clamp(0.0, 1.0) + out_video = video.clone() + region = out_video[:, :, :, y0:y1, x0:x1] + out_video[:, :, :, y0:y1, x0:x1] = region + (refined_down.to(region.device) - region) * m + + out = dict(latent) + if len(tensors) > 1: + out["samples"] = comfy.nested_tensor.NestedTensor([out_video] + list(tensors[1:])) + else: + out["samples"] = out_video + note = (f"segmented_detail({cost_note}, '{target_str}', tube={y1 - y0}x{x1 - x0}" + f"/{lat_h}x{lat_w}, s={strength})") + return out, note diff --git a/docs/images/easy-gen.png b/docs/images/easy-gen.png new file mode 100644 index 00000000..928e93af Binary files /dev/null and b/docs/images/easy-gen.png differ diff --git a/docs/images/hub.png b/docs/images/hub.png new file mode 100644 index 00000000..cfb73bb5 Binary files /dev/null and b/docs/images/hub.png differ diff --git a/easy_gen/__init__.py b/easy_gen/__init__.py new file mode 100644 index 00000000..57b35744 --- /dev/null +++ b/easy_gen/__init__.py @@ -0,0 +1 @@ +from . import server # noqa: F401 registers /funpack/easy/* routes diff --git a/easy_gen/frontend/api.js b/easy_gen/frontend/api.js new file mode 100644 index 00000000..98a03763 --- /dev/null +++ b/easy_gen/frontend/api.js @@ -0,0 +1,319 @@ +// Thin backend client — FORKED from movie_editor/frontend/api.js on purpose: +// Easy Gen is served from /funpack/easy/ but there is no /funpack/easy/api/* +// backend (see easy_gen/server.py) — Easy Gen reuses the Movie Editor's +// project/generate/media/models API as-is, so BASE must stay pinned to +// /funpack/movie regardless of the page's own URL. Keep every other method +// here in sync with the Editor's api.js; only this constant differs. +(function () { + const BASE = "/funpack/movie"; + const API = (p) => `${BASE}/api${p}`; + const FUNPACK = "/funpack"; + + async function funpackFetch(method, path, body) { + const opts = { method, headers: {}, cache: "no-store" }; + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + const res = await fetch(path, opts); + if (!res.ok) { + let payload = null; + try { payload = await res.json(); } catch (_) {} + throw new Error(readApiError(res, payload)); + } + return res; + } + + function readApiError(res, payload) { + if (payload && typeof payload === "object") { + const detail = payload.detail; + if (typeof detail === "string" && detail.trim()) return detail.trim(); + if (Array.isArray(detail)) { + const parts = detail.map((item) => { + if (item && typeof item === "object" && item.msg) return String(item.msg); + return JSON.stringify(item); + }).filter(Boolean); + if (parts.length) return parts.join("; "); + } + if (payload.error) return String(payload.error); + if (payload.parse_errors && typeof payload.parse_errors === "object") { + return Object.entries(payload.parse_errors).map(([k, v]) => `${k}: ${v}`).join("; "); + } + } + const status = res && res.status ? `HTTP ${res.status}` : ""; + const statusText = (res && res.statusText ? res.statusText : "").trim(); + return statusText || status || "Request failed"; + } + + async function j(method, url, body) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + const res = await fetch(url, opts); + if (!res.ok) { + let payload = null; + try { payload = await res.json(); } catch (_) {} + throw new Error(readApiError(res, payload)); + } + return res.status === 204 ? null : res.json(); + } + + const ClientAPI = { + health: () => j("GET", API("/health")), + + // projects + listProjects: () => j("GET", API("/projects")), + createProject: (name) => j("POST", API("/projects"), { name }), + getProject: (id) => j("GET", API(`/projects/${id}`)), + saveProject: (id, data) => j("PUT", API(`/projects/${id}`), data), + deleteProject: (id) => j("DELETE", API(`/projects/${id}`)), + downloadProjectUrl: (id) => API(`/projects/${id}/download`), + importProject: (data) => j("POST", API("/projects/import"), data), + + // timeline preview + preview: (id, includeExcluded, forGeneration = true) => + j("GET", API(`/projects/${id}/preview?include_excluded=${includeExcluded ? "true" : "false"}&for_generation=${forGeneration ? "true" : "false"}`)), + parsePrompt: (id, prompt) => j("POST", API(`/projects/${id}/parse`), { prompt }), + + // libraries + transitions: () => j("GET", API("/library/transitions")), + saveTransition: (item) => j("POST", API("/library/transitions"), item), + deleteTransition: (name) => j("DELETE", API(`/library/transitions/${encodeURIComponent(name)}`)), + exportTransitionsUrl: () => API("/library/transitions/export"), + importTransitions: (data, mode) => j("POST", API(`/library/transitions/import?mode=${mode || "merge"}`), data), + clearTransitions: () => j("POST", API("/library/transitions/clear"), {}), + shortcuts: () => j("GET", API("/library/shortcuts")), + suggestionStats: () => j("GET", API("/library/suggestion_stats")), + saveCategory: (payload) => j("POST", API("/library/categories"), payload), + saveShortcut: (item) => j("POST", API("/library/shortcuts"), item), + deleteShortcut: (name) => j("DELETE", API(`/library/shortcuts/${encodeURIComponent(name)}`)), + exportShortcutsUrl: () => API("/library/shortcuts/export"), + importShortcuts: (data, mode) => j("POST", API(`/library/shortcuts/import?mode=${mode || "merge"}`), data), + clearShortcuts: () => j("POST", API("/library/shortcuts/clear"), {}), + revolverSettings: () => j("GET", API("/library/revolver")), + setRevolverSettings: (payload) => j("POST", API("/library/revolver"), payload), + + // FunPack file manager (Composer ▸ Files) + listFiles: () => j("GET", API("/files")), + deleteFile: (group, name) => j("DELETE", API(`/files/${encodeURIComponent(group)}/${encodeURIComponent(name)}`)), + clearFiles: (group) => j("POST", API(`/files/${encodeURIComponent(group)}/clear`), {}), + nleLibrary: () => j("GET", API("/library/nle")), + + // media bin + listMedia: () => j("GET", API("/media")), + mediaUrl: (id) => API(`/media/${encodeURIComponent(id)}`), + deleteMedia: (id) => j("DELETE", API(`/media/${encodeURIComponent(id)}`)), + renameMedia: (id, name) => j("PATCH", API(`/media/${encodeURIComponent(id)}`), { name }), + importClipToMediaBin: (clip, name) => j("POST", API("/media/import-clip"), { clip, name: name || null }), + async uploadMedia(file) { + const fd = new FormData(); fd.append("file", file, file.name); + const res = await fetch(API("/media"), { method: "POST", body: fd }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.statusText); + return res.json(); + }, + + // models / node slots + nodeRoles: () => j("GET", API("/node-roles")), + nodeCandidates: (role, refresh) => j("GET", API(`/node-candidates/${role}${refresh ? "?refresh=true" : ""}`)), + allNodes: () => j("GET", API("/all-nodes")), + nodeSpec: (cls) => j("GET", API(`/node/${encodeURIComponent(cls)}`)), + pipelinePorts: () => j("GET", API("/pipeline-ports")), + pipelineDeps: () => j("GET", API("/pipeline-deps")), + pipelineDepsInstall: (packIds) => j("POST", API("/pipeline-deps/install"), { pack_ids: packIds }), + pipelineDepsInstallManager: () => j("POST", API("/pipeline-deps/install"), { install_manager: true }), + pipelineDepsInstallStatus: (jobId) => j("GET", API(`/pipeline-deps/install/${encodeURIComponent(jobId)}`)), + pipelineDepsInstallCancel: (jobId) => j("POST", API(`/pipeline-deps/install/${encodeURIComponent(jobId)}/cancel`), {}), + imageTargets: (pid) => j("GET", API("/image-targets" + (pid ? `?pid=${encodeURIComponent(pid)}` : ""))), + // Models config is per-project; pass the project id. Falls back to the global + // default route (used as the seed/template) when no project is given. + coreGraph: (pid) => j("GET", API("/core-graph" + (pid ? `?pid=${encodeURIComponent(pid)}` : ""))), + getModels: (pid) => j("GET", API(pid ? `/projects/${pid}/models` : "/models")), + saveModels: (pid, data) => j("PUT", API(pid ? `/projects/${pid}/models` : "/models"), data), + refreshModels: () => j("POST", API("/models/refresh")), + parseWorkflow: (workflow) => j("POST", API("/workflow/parse"), { workflow }), + applyWorkflow: (pid, workflow, bindings) => + j("POST", API(`/projects/${pid}/workflow/apply`), { workflow, bindings }), + restart: () => j("POST", API("/restart")), + gitStatus: () => j("GET", API("/git/status")), + gitUpdate: (branch) => j("POST", API("/git/update"), branch ? { branch } : {}), + gitCheckout: (branch) => j("POST", API("/git/checkout"), { branch }), + + // generate (a single scene, or an explicit run of scene ids = one chain request) + generate: (id, onlyScene, sceneIds, resetSession, nodeOverrides) => + j("POST", API(`/projects/${id}/generate`), { only_scene: onlyScene || null, scene_ids: sceneIds || null, reset_session: !!resetSession, node_overrides: nodeOverrides || null }), + status: (id, promptId) => j("GET", API(`/projects/${id}/status/${promptId}`)), + progress: () => j("GET", API("/progress")), + // The editor's own in-flight generation, recovered from ComfyUI's queue (survives a UI reload). + active: () => j("GET", API("/active")), + ratingLabels: () => j("GET", API("/rating-labels")), + log: (limit) => j("GET", API("/log" + (limit ? `?limit=${limit}` : ""))), + interrupt: () => j("POST", API("/interrupt")), + + // ComfyUI temp folder browser (scene previews & other transient outputs, wiped on restart). + listTemp: () => j("GET", API("/temp")), + // Served by ComfyUI's own same-origin /view endpoint — no editor route needed. + tempFileUrl: (f) => "/view?" + new URLSearchParams({ + filename: f.filename, subfolder: f.subfolder || "", type: "temp", t: String(f.mtime || Date.now()), + }).toString(), + async downloadTempFile(f) { + const res = await fetch(this.tempFileUrl(f), { cache: "no-store" }); + if (!res.ok) throw new Error(`Could not fetch ${f.filename} (HTTP ${res.status})`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = f.filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }, + renderFinal: (id, clips) => j("POST", API(`/projects/${id}/render`), { clips }), + renderFinalStatus: (id, jobId) => j("GET", API(`/projects/${id}/render/${encodeURIComponent(jobId)}`)), + exportClip: (id, clip) => j("POST", API(`/projects/${id}/export-clip`), { clip }), + exportClipsCombined: (id, clips) => j("POST", API(`/projects/${id}/export-clips`), { clips }), + exportClipsStatus: (id, jobId) => j("GET", API(`/projects/${id}/export-clips/${encodeURIComponent(jobId)}`)), + resultUrl: (id, m) => + API(`/projects/${id}/result?filename=${encodeURIComponent(m.filename)}`) + + `&subfolder=${encodeURIComponent(m.subfolder || "")}&type=${encodeURIComponent(m.type || "output")}`, + previewSegmentUrl: (id, sceneId, spec) => { + let u = API(`/projects/${id}/preview-segment/${encodeURIComponent(sceneId)}`); + const m = spec?.media; + if (m?.filename) { + const q = new URLSearchParams({ + filename: m.filename, + subfolder: m.subfolder || "", + type: m.type || "output", + render_in: String(spec.renderIn != null ? spec.renderIn : 0), + }); + // dur is in the URL for two reasons: ghosts have no scene server-side (the trim + // window must travel in the query), and it versions the browser cache — segments + // are cached for an hour, so a timeline trim must produce a different URL. + if (spec.dur != null) q.set("dur", String(spec.dur)); + u += "?" + q.toString(); + } + return u; + }, + + // FunPack refinement keys (ComfyUI root routes, same as Studio / Refinement Key Loader) + refinementKeys: async () => { + const res = await funpackFetch("GET", `${FUNPACK}/refinement_keys?cache_bust=${Date.now()}`); + return res.json(); + }, + importRefinementKey: async (data, opts = {}) => { + const ow = opts.overwrite ? "&overwrite=true" : ""; + const json = JSON.stringify(data); + const bytes = new TextEncoder().encode(json); + // A 409 means a key of that name already exists — surface it as a tagged + // error so the caller can ask the user to overwrite (then retry). + const finish = async (res) => { + if (res.status === 409) { + let p = null; try { p = await res.json(); } catch (_) {} + const err = new Error((p && p.error) || "Refinement key already exists."); + err.exists = true; err.key = (p && p.key) || ""; + throw err; + } + if (!res.ok) { + let p = null; try { p = await res.json(); } catch (_) {} + throw new Error(readApiError(res, p)); + } + return res.json(); + }; + // Reverse proxies on Vast.ai / Runpod cap the request body well below + // ComfyUI's 100 MB, so a one-shot POST of a large key gets HTTP 413 at the + // proxy. Keep a fast single POST for small keys; stream big ones in chunks + // (each far under any proxy limit) so they can never be rejected. + const CHUNK = 256 * 1024; + if (bytes.length <= CHUNK) { + const res = await fetch(`${FUNPACK}/refinement_keys/import?_=${Date.now()}${ow}`, { + method: "POST", cache: "no-store", + headers: { "Content-Type": "application/json" }, body: json, + }); + if (res.status !== 413) return finish(res); // 413 → fall through to chunked + } + const uploadId = `${Date.now()}_${Math.random().toString(36).slice(2)}`; + const postChunk = async (path, body) => { + const res = await fetch(path, { + method: "POST", cache: "no-store", + headers: { "Content-Type": "application/octet-stream" }, body, + }); + if (!res.ok) { + let payload = null; + try { payload = await res.json(); } catch (_) {} + throw new Error(readApiError(res, payload)); + } + return res; + }; + let index = 0; + for (let off = 0; off < bytes.length; off += CHUNK, index++) { + // Slice by bytes (not string chars) so a multi-byte char is never split + // across the seam where the two halves get re-encoded independently. + await postChunk( + `${FUNPACK}/refinement_keys/import_chunk?upload_id=${encodeURIComponent(uploadId)}&index=${index}`, + bytes.slice(off, off + CHUNK), + ); + } + return finish(await fetch( + `${FUNPACK}/refinement_keys/import_finalize?upload_id=${encodeURIComponent(uploadId)}${ow}`, + { method: "POST", cache: "no-store" }, + )); + }, + async exportRefinementKeyFile(key) { + const res = await fetch( + `${FUNPACK}/refinement_keys/export?key=${encodeURIComponent(key)}&cache_bust=${Date.now()}`, + { cache: "no-store" }, + ); + if (!res.ok) { + let payload = null; + try { payload = await res.json(); } catch (_) {} + throw new Error(readApiError(res, payload)); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${key}.json`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }, + + // Atomically delete a refinement key and ALL its sidecars (value function, + // blessed attention / K-V banks, creativity latent, velocity store). Deleting + // only the visible .json orphans those, and they keep steering future runs + // across restarts — the backend sweep is what makes "key deleted" mean it. + async deleteRefinementKey(key) { + const res = await funpackFetch( + "POST", `${FUNPACK}/refinement_keys/delete?key=${encodeURIComponent(key)}&_=${Date.now()}`, + ); + if (!res.ok) { + let payload = null; + try { payload = await res.json(); } catch (_) {} + throw new Error(readApiError(res, payload)); + } + return res.json(); + }, + + // The keyless Absolute "global taste" store — learns from every rated generation + // across all prompts, invisible in the key list, applied only in absolute/both + // steer mode, and otherwise only wiped by Session Reset. + async absoluteStoreInfo() { + const res = await funpackFetch("GET", `${FUNPACK}/refinement_keys/absolute?_=${Date.now()}`); + return res.json(); + }, + async clearAbsoluteStore() { + const res = await funpackFetch("POST", `${FUNPACK}/refinement_keys/clear_absolute?_=${Date.now()}`); + if (!res.ok) { + let payload = null; + try { payload = await res.json(); } catch (_) {} + throw new Error(readApiError(res, payload)); + } + return res.json(); + }, + }; + + window.MovieEditorAPI = ClientAPI; +})(); diff --git a/easy_gen/frontend/app.js b/easy_gen/frontend/app.js new file mode 100644 index 00000000..2c2b114e --- /dev/null +++ b/easy_gen/frontend/app.js @@ -0,0 +1,199 @@ +// Easy Gen entrypoint: wires the big-preview / prompt / upload / generate bar +// to the shared easy_store.js + preview.js, and opens the "FunPack Easy Gen" +// project picker on boot. No re-attach-on-reload support yet (the Editor's +// /api/active flow) — a reload while a run is in flight loses the progress +// view, though the run itself still completes server-side. +(function () { + const S = window.Store; + const API = window.MovieEditorAPI; + + const LAST_PROJECT_KEY = "funpack_easy_last_project"; + + const nameEl = document.getElementById("easy-project-name"); + const healthEl = document.getElementById("easy-health-chip"); + const promptEl = document.getElementById("easy-prompt"); + const uploadBtn = document.getElementById("easy-upload-btn"); + const uploadInput = document.getElementById("easy-upload-input"); + const galleryBtn = document.getElementById("easy-gallery-btn"); + const generateBtn = document.getElementById("easy-generate-btn"); + const advancedBtn = document.getElementById("easy-advanced-btn"); + const projectsBtn = document.getElementById("easy-projects-btn"); + const saveBtn = document.getElementById("easy-save-btn"); + const exportBtn = document.getElementById("easy-export-btn"); + + const preview = window.EasyPreview.create(document.getElementById("easy-preview")); + + let saveTimer = null; + function scheduleSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { S.save().catch(() => {}); }, 800); + } + + let promptFocused = false; + promptEl.addEventListener("focus", () => { promptFocused = true; }); + promptEl.addEventListener("blur", () => { promptFocused = false; }); + promptEl.addEventListener("input", () => { + S.scheduleGlobalPromptApply(promptEl.value); + }); + // Self-contained: reads Store.getEditorSetting("autocomplete")/get().shortcuts + // and no-ops until a project + shortcut library are loaded. + window.ShortcutAutocomplete?.attach(promptEl); + + function removeUpload() { + S.setSceneMedia(null, null); + scheduleSave(); + } + preview.setOnClearUpload(removeUpload); + + let lastProjectId = null; + function render(st) { + const p = st.project; + nameEl.textContent = p ? p.name : "No project"; + generateBtn.disabled = !p; + uploadBtn.disabled = !p; + galleryBtn.disabled = !p; + saveBtn.disabled = !p; + exportBtn.disabled = !p; + if (p && !promptFocused) { + const text = p.global_prompt || ""; + if (promptEl.value !== text) promptEl.value = text; + } + const source = p?.scenes[0]?.source; + const attached = source && source.type !== "empty" && source.media_ref; + uploadBtn.textContent = attached ? "⬆ Uploaded ✕" : "⬆ Upload"; + preview.setUpload(attached ? API.mediaUrl(source.media_ref) : null, source?.type === "v2v" ? "video" : "image"); + + const pid = p ? p.id : null; + if (pid !== lastProjectId) { + lastProjectId = pid; + preview.stopPolling(); + preview.showEmpty(); + generateBtn.textContent = "Generate"; + } + } + S.subscribe(render); + + uploadBtn.onclick = () => { + const st = S.get(); + const attached = st.project && st.project.scenes[0]?.source?.type !== "empty"; + if (attached) { + removeUpload(); + return; + } + uploadInput.click(); + }; + + uploadInput.onchange = async () => { + const file = uploadInput.files?.[0]; + uploadInput.value = ""; + if (!file) return; + uploadBtn.disabled = true; + uploadBtn.textContent = "Uploading…"; + try { + const res = await API.uploadMedia(file); + const media = res.media?.[0]; // POST /media wraps the result: { media: [ {id, ...} ] } + if (!media) throw new Error("Upload succeeded but returned no media entry."); + const kind = file.type.startsWith("video") ? "video" : "image"; + S.setSceneMedia(media.id, kind); + await S.save(); + S.loadMedia(); + } catch (e) { + alert("Upload failed: " + (e.message || e)); + } finally { + render(S.get()); + } + }; + + // Easy Gen has no rating UI, so Studio must never apply learned/rated steering here — + // forced at the graph level (not just hidden in Engine settings) so a project edited in + // the Cutting Room with these on still generates plainly when run from Easy Gen. + // "studio"/"sampler" are the built-in pipeline's fixed graph keys (see backend/builder.py). + const EASY_MODE_OVERRIDES = [ + { node: "studio", input: "mode", value: "Prompt only" }, + { node: "sampler", input: "embed_guidance", value: false }, + { node: "sampler", input: "score_slider", value: false }, + { node: "sampler", input: "output_guidance", value: false }, + { node: "sampler", input: "dynashift", value: false }, + { node: "sampler", input: "taste_nearest_prompt", value: false }, + ]; + + generateBtn.onclick = async () => { + const st = S.get(); + if (!st.project) return; + if (!(st.project.global_prompt || "").trim()) { + alert("Type a prompt first."); + return; + } + generateBtn.disabled = true; + generateBtn.textContent = "Generating…"; + try { + await S.save(); + const resetSession = S.takeResetSessionFlag(); + const res = await API.generate(st.project.id, null, null, resetSession, EASY_MODE_OVERRIDES); + preview.watch(st.project.id, res.prompt_id, { + onDone: () => { generateBtn.disabled = false; generateBtn.textContent = "Generate"; }, + }); + } catch (e) { + preview.showError("Generate failed: " + (e.message || e)); + generateBtn.disabled = false; + generateBtn.textContent = "Generate"; + } + }; + + advancedBtn.onclick = () => window.SettingsWindow.open("generation"); + projectsBtn.onclick = () => window.ProjectMenu.open({ dismissable: !!S.get().project }); + galleryBtn.onclick = () => window.EasyGallery.open(); + + // "Save" persists into FunPack's own project store on THIS machine — the same mechanism + // as the Editor's autosave. It doesn't help moving a project off a rental GPU box. This + // downloads the actual .funpack_project.json file (re-importable via "Load Project + // File…" on another machine), same as the Editor's File ▸ "Save Project File…". + exportBtn.onclick = async () => { + const st = S.get(); + if (!st.project) return; + try { await S.save(); } catch (_) { /* export the last-saved version anyway */ } + const a = document.createElement("a"); + a.href = API.downloadProjectUrl(st.project.id); + a.download = ""; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + }; + + let saveResetTimer = null; + saveBtn.onclick = async () => { + if (saveResetTimer) clearTimeout(saveResetTimer); + saveBtn.disabled = true; + saveBtn.textContent = "Saving…"; + try { + await S.save(); + saveBtn.textContent = "Saved ✓"; + } catch (e) { + saveBtn.textContent = "Save failed"; + alert("Save failed: " + (e.message || e)); + } finally { + saveResetTimer = setTimeout(() => { + saveBtn.textContent = "Save"; + saveBtn.disabled = !S.get().project; + }, 1200); + } + }; + + async function boot() { + S.refreshHealth(); + S.loadLibraries(); + S.loadMedia(); + setInterval(() => S.refreshHealth(), 15000); + S.subscribe((st) => { + healthEl.textContent = st.health?.ok ? "● online" : "● offline"; + healthEl.classList.toggle("bad", !(st.health && st.health.ok)); + if (st.project) localStorage.setItem(LAST_PROJECT_KEY, st.project.id); + }); + + const lastId = localStorage.getItem(LAST_PROJECT_KEY); + if (lastId) { + try { await S.loadProject(lastId); return; } catch (_) { /* fall through to picker */ } + } + window.ProjectMenu.open({ dismissable: false }); + } + + boot(); +})(); diff --git a/easy_gen/frontend/easy.css b/easy_gen/frontend/easy.css new file mode 100644 index 00000000..da5908a1 --- /dev/null +++ b/easy_gen/frontend/easy.css @@ -0,0 +1,98 @@ +/* Easy Gen layout on top of the Editor's shared styles.css (design tokens, + .btn/.modal/.sw-*/.welcome-* rules) — big preview, prompt bar, nothing else. */ + +html, body.easy-body { + margin: 0; height: 100%; background: var(--ink-0); color: var(--text); + font-family: var(--font-ui); +} + +.easy-app { + height: 100vh; display: flex; flex-direction: column; +} + +.easy-top { + height: var(--menubar-h); flex: none; display: flex; align-items: center; gap: 14px; + padding: 0 16px; border-bottom: 1px solid var(--line); background: var(--ink-1); +} +.easy-project-name { color: var(--muted); font-size: 13px; } +.easy-top .easy-health-chip { margin-left: auto; } +a.brand { text-decoration: none; color: inherit; } +a.brand:hover .brand-sub { color: var(--amber-2); } + +.easy-preview { + flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; + background: var(--ink-0); position: relative; padding: 24px; +} +.easy-preview-inner { + width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; +} +.easy-preview-inner img, .easy-preview-inner video { + max-width: 100%; max-height: 100%; border-radius: var(--radius); box-shadow: var(--shadow); + background: #000; +} +.easy-preview-empty { + color: var(--faint); font-size: 14px; font-family: var(--font-display); +} +.easy-preview-error { color: var(--danger); max-width: 60ch; text-align: center; white-space: pre-wrap; } + +.easy-preview-close { + position: absolute; top: 34px; right: 34px; width: 28px; height: 28px; padding: 0; + border-radius: 50%; background: rgba(12,11,9,.72); border: 1px solid var(--line); + color: var(--text); font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(2px); transition: border-color .12s, color .12s; +} +.easy-preview-close[hidden] { display: none; } /* class display:flex above would otherwise beat [hidden] */ +.easy-preview-close:hover { border-color: var(--danger); color: var(--danger); } +.easy-preview-badge { + position: absolute; top: 34px; left: 34px; padding: 4px 10px; border-radius: 999px; + background: rgba(12,11,9,.72); border: 1px solid var(--line); color: var(--muted); + font-size: 11.5px; letter-spacing: .3px; backdrop-filter: blur(2px); +} + +.easy-progress { + position: absolute; left: 24px; right: 24px; bottom: 24px; + background: var(--ink-1); border: 1px solid var(--line); border-radius: var(--radius); + padding: 10px 14px; box-shadow: var(--shadow); +} +.easy-progress-bar { + height: 6px; border-radius: 4px; background: linear-gradient(90deg, var(--amber), var(--amber-2)); + width: 0%; transition: width .2s ease; margin-bottom: 6px; +} +.easy-progress-label { font-size: 12px; color: var(--muted); font-family: var(--font-mono); } + +.easy-bar { + flex: none; display: flex; align-items: flex-end; gap: 12px; + padding: 14px 16px; border-top: 1px solid var(--line); background: var(--ink-1); +} +.easy-upload-btn, .easy-gallery-btn { flex: none; align-self: stretch; padding: 0 16px; } +.easy-prompt { + flex: 1; resize: none; background: var(--ink-2); border: 1px solid var(--line); + border-radius: var(--radius); color: var(--text); font: 14px/1.4 var(--font-ui); + padding: 10px 12px; +} +.easy-prompt:focus { outline: none; border-color: var(--amber); } +.easy-actions { flex: none; display: flex; flex-direction: column; align-items: stretch; gap: 6px; } +.easy-generate-btn { padding: 10px 26px; font-size: 14px; } +.easy-advanced-btn { align-self: center; } + +.es-io-controls { display: flex; align-items: center; gap: 8px; } +.es-io-mode { background: var(--ink-2); border: 1px solid var(--line); color: var(--text); + border-radius: 6px; font-size: 12px; padding: 4px 6px; } + +.gal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; } +.gal-cell { cursor: pointer; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; + background: var(--ink-2); transition: border-color .15s; } +.gal-cell:hover { border-color: var(--amber); } +.gal-thumb { position: relative; aspect-ratio: 1 / 1; background: var(--ink-0); display: flex; align-items: center; justify-content: center; } +.gal-thumb img, .gal-thumb video { width: 100%; height: 100%; object-fit: cover; } +.gal-name { padding: 6px 8px; font-size: 11.5px; color: var(--muted); white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; } + +.gal-pin-btn { position: absolute; top: 4px; right: 4px; z-index: 2; width: 22px; height: 22px; + border-radius: 6px; background: rgba(10, 9, 7, .72); border: 1px solid var(--line); + filter: grayscale(1); font-size: 12px; cursor: pointer; display: flex; align-items: center; + justify-content: center; padding: 0; } +.gal-pin-btn:hover { filter: none; } +.gal-pin-btn.active { filter: none; background: var(--amber-soft); border-color: var(--amber); } +.gal-pin-badge { position: absolute; top: 4px; left: 4px; z-index: 2; font-size: 10.5px; + background: rgba(10, 9, 7, .72); border-radius: 4px; padding: 1px 4px; line-height: 1.4; } diff --git a/easy_gen/frontend/easy_gallery.js b/easy_gen/frontend/easy_gallery.js new file mode 100644 index 00000000..2806fec7 --- /dev/null +++ b/easy_gen/frontend/easy_gallery.js @@ -0,0 +1,116 @@ +// "Gallery" picker: choose an already-uploaded media-bin file as the i2v anchor, +// without re-uploading it. Standalone modal (same modal-overlay/.modal shell as +// project_menu.js) since Easy Gen has no Media Bin panel of its own. +(function () { + const { el, clear } = window.dom; + const API = window.MovieEditorAPI; + const S = window.Store; + let overlay = null; + + function close() { + if (overlay) { overlay.remove(); overlay = null; } + } + + // Continuity pin, right on the gallery card — same setting as Engine settings ▸ + // Continuity ▸ Identity pin, just quicker to reach (mirrors mediabrowser.js's card + // pin button in the Editor). Images only, chain-sampler pipelines only. + const pinEls = []; // { id, btn, badge } for every image cell in the currently-open grid + + function refreshPins() { + const cs = (S.get().project || {}).continuity_settings || {}; + pinEls.forEach(({ id, btn, badge }) => { + const isPin = cs.identity_pin_ref === id; + btn.classList.toggle("active", isPin); + btn.title = isPin + ? "Continuity pin — click to unpin" + : "Pin as identity guide for every scene (continuity pin)" + + (cs.auto_enabled === false ? " — also turns Auto continuity back on" : ""); + badge.hidden = !isPin; + }); + } + + function togglePin(m) { + const cs = (S.get().project || {}).continuity_settings || {}; + const isPin = cs.identity_pin_ref === m.id; + const patch = { ...cs, identity_pin_ref: isPin ? null : m.id }; + if (!isPin && patch.auto_enabled === false) patch.auto_enabled = true; + S.patchProject({ continuity_settings: patch }); + refreshPins(); + } + + function cellFor(m) { + const cell = el("div", "gal-cell"); + cell.title = m.name || m.id; + const thumb = el("div", "gal-thumb"); + const url = API.mediaUrl(m.id); + if (m.kind === "video") { + const v = el("video"); + v.src = url; v.muted = true; v.preload = "metadata"; + thumb.append(v); + } else { + const img = el("img"); + img.src = url; img.loading = "lazy"; + thumb.append(img); + } + if (m.kind === "image" && window.PipelineCaps?.usesChainSampler(S.get())) { + const btn = el("button", "gal-pin-btn", "📌"); + btn.type = "button"; + btn.onclick = (e) => { e.stopPropagation(); togglePin(m); }; + thumb.append(btn); + const badge = el("span", "gal-pin-badge", "📌"); + badge.hidden = true; + thumb.append(badge); + pinEls.push({ id: m.id, btn, badge }); + } + cell.append(thumb); + cell.append(el("div", "gal-name", m.name || m.id)); + cell.onclick = async () => { + S.setSceneMedia(m.id, m.kind === "video" ? "video" : "image"); + await S.save(); + close(); + }; + return cell; + } + + async function open() { + if (!S.get().project) return; + close(); + pinEls.length = 0; + overlay = el("div", "modal-overlay"); + const box = el("div", "modal modal-wide"); + const head = el("div", "modal-head"); + head.append(el("div", "modal-title", "Gallery — choose an anchor")); + const x = el("button", "btn ghost tiny", "✕"); + x.onclick = close; + head.append(el("div", "modal-head-right"), x); + box.append(head); + + const content = el("div", "modal-content gal-grid"); + content.append(el("div", "sw-hint", "Loading…")); + box.append(content); + + overlay.append(box); + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + document.addEventListener("keydown", function onKey(e) { + if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); } + }); + document.body.append(overlay); + + try { + const r = await API.listMedia(); + const items = (r.media || []).filter((m) => m.kind === "image" || m.kind === "video"); + clear(content); + if (!items.length) { + content.append(el("div", "sw-hint", "No uploaded media yet — use Upload first.")); + return; + } + items.forEach((m) => content.append(cellFor(m))); + refreshPins(); + } catch (e) { + clear(content); + content.append(el("div", "sw-hint", "Could not load media: " + (e.message || e))); + } + } + + window.EasyGallery = { open, close }; +})(); diff --git a/easy_gen/frontend/easy_maintenance.js b/easy_gen/frontend/easy_maintenance.js new file mode 100644 index 00000000..697547a0 --- /dev/null +++ b/easy_gen/frontend/easy_maintenance.js @@ -0,0 +1,104 @@ +// window.FunPackMaintenance shim — ports the five actions menubar.js exposes +// for the Settings window's built-in "Refinement & Taste" / "Updates & ComfyUI" +// sections (settings_window.js), without pulling in the other ~300 lines of +// menubar.js (menu rendering, keyboard shortcuts, timeline-specific state). +// Same API calls, same confirm/alert flows as the Editor. +(function () { + const API = window.MovieEditorAPI; + + function projectRefinementKey() { + return window.Store?.get().project?.refinement_key || ""; + } + + const refinementKeyImportInput = (function () { + const inp = document.createElement("input"); + inp.type = "file"; inp.accept = ".json,application/json"; inp.style.display = "none"; + inp.onchange = async () => { + const file = inp.files?.[0]; + inp.value = ""; + if (!file) return; + try { + const data = JSON.parse(await file.text()); + let res; + try { + res = await API.importRefinementKey(data); + } catch (e) { + if (!e.exists) throw e; + if (!confirm(`A refinement key named "${e.key}" already exists. Overwrite it?`)) return; + res = await API.importRefinementKey(data, { overwrite: true }); + } + alert(`Imported refinement key "${res.key}".`); + } catch (e) { + alert("Refinement key import failed: " + (e.message || e)); + } + }; + document.body.appendChild(inp); + return inp; + })(); + + async function exportRefinementKey() { + let payload; + try { payload = await API.refinementKeys(); } + catch (e) { alert("Could not list refinement keys: " + (e.message || e)); return; } + const names = (payload.keys || []).filter((k) => k && k !== "-None-"); + if (!names.length) { alert("No refinement keys on disk yet."); return; } + const cur = projectRefinementKey(); + if (!window.SlotPicker) return; + window.SlotPicker.open({ + title: "Export refinement key", + options: names.map((k) => ({ value: k, label: k, hint: k === cur ? "project session" : "" })), + onPick: async (key) => { + try { await API.exportRefinementKeyFile(key); } + catch (e) { alert("Refinement key export failed: " + (e.message || e)); } + }, + }); + } + + async function deleteRefinementKey() { + let payload; + try { payload = await API.refinementKeys(); } + catch (e) { alert("Could not list refinement keys: " + (e.message || e)); return; } + const names = (payload.keys || []).filter((k) => k && k !== "-None-"); + if (!names.length) { alert("No refinement keys on disk yet."); return; } + const cur = projectRefinementKey(); + if (!window.SlotPicker) return; + window.SlotPicker.open({ + title: "Delete refinement key", + options: names.map((k) => ({ value: k, label: k, hint: k === cur ? "project session" : "" })), + onPick: async (key) => { + if (!confirm(`Delete refinement key "${key}"?\n\nThis removes its learned state AND all sidecars (value function, blessed attention/K-V banks, creativity latent, velocity memory). This cannot be undone.`)) { + return; + } + try { await API.deleteRefinementKey(key); alert(`Deleted "${key}".`); } + catch (e) { alert("Delete failed: " + (e.message || e)); } + }, + }); + } + + async function clearGlobalTaste() { + let info; + try { info = await API.absoluteStoreInfo(); } + catch (e) { alert("Could not read the global-taste store: " + (e.message || e)); return; } + if (!info.exists) { alert("The Absolute global-taste store is already empty."); return; } + if (!confirm(`Clear the Absolute global-taste store?\n\nIt has pooled ${info.total_iterations} rated generation(s) (${info.liked_count} liked / ${info.bad_count} disliked directions) across all prompts. It learns from every rated run and is applied only in absolute/both steer mode. This cannot be undone.`)) { + return; + } + try { await API.clearAbsoluteStore(); alert("Cleared the Absolute global-taste store."); } + catch (e) { alert("Clear global taste failed: " + (e.message || e)); } + } + + async function restartComfy() { + if (!confirm("Restart ComfyUI now?\n\nThe server will be down for ~10-40s and any running generation will be lost. This page reloads automatically when it's back.")) return; + const msg = window.FunPackRestart?.showOverlay?.("Restarting ComfyUI…\nThis page will reload when it's back."); + try { await API.restart(); } catch (_) { /* connection drops as it execv's - expected */ } + window.FunPackRestart?.waitForReload?.(msg, Date.now()); + } + + window.FunPackMaintenance = { + exportRefinementKey, + importRefinementKeyFile: () => refinementKeyImportInput.click(), + deleteRefinementKey, + clearGlobalTaste, + restartComfy, + }; +})(); diff --git a/easy_gen/frontend/easy_settings.js b/easy_gen/frontend/easy_settings.js new file mode 100644 index 00000000..80a0fb93 --- /dev/null +++ b/easy_gen/frontend/easy_settings.js @@ -0,0 +1,90 @@ +// Easy Gen's own slimmed "Generation" Settings section — modeled on the +// field(label, control, hint) row pattern in movie_editor/frontend/engine_settings.js, +// but limited to plain Project-level fields (no scene-chain/continuity/overlay +// knobs, which only make sense with multiple scenes in the full Editor). +(function () { + const { el } = window.dom; + + function field(labelText, control, hint) { + const row = el("div", "sw-row eng-field"); + const main = el("div", "sw-row-main"); + main.append(el("div", "sw-row-title", labelText)); + if (hint) main.append(el("div", "sw-row-hint", hint)); + row.append(main, control); + return row; + } + + function numberInput(value, onChange, opts = {}) { + const inp = el("input", "sw-input"); + inp.type = "number"; + if (opts.min != null) inp.min = opts.min; + if (opts.step != null) inp.step = opts.step; + inp.value = value; + inp.style.width = "90px"; + inp.onchange = () => onChange(Number(inp.value)); + return inp; + } + + function textInput(value, onChange, placeholder) { + const inp = el("input", "sw-input"); + inp.type = "text"; + inp.value = value || ""; + if (placeholder) inp.placeholder = placeholder; + inp.style.width = "220px"; + inp.onchange = () => onChange(inp.value); + return inp; + } + + function mount(body) { + const S = window.Store; + const wrap = el("div", "sw-stack"); + body.append(wrap); + + function render() { + body.innerHTML = ""; + body.append(wrap); + wrap.innerHTML = ""; + const st = S.get(); + const p = st.project; + if (!p) { + wrap.append(el("div", "sw-hint", "Open or create a project first.")); + return; + } + const commit = async (mut) => { mut(p); await S.save(); }; + + wrap.append(el("div", "sw-rows-label", "Output")); + const out = el("div", "sw-rows"); + out.append(field("Width", numberInput(p.width, (v) => commit((pr) => pr.width = v), { min: 64, step: 16 }))); + out.append(field("Height", numberInput(p.height, (v) => commit((pr) => pr.height = v), { min: 64, step: 16 }))); + out.append(field("Frames", numberInput(p.num_frames_per_scene, (v) => commit((pr) => pr.num_frames_per_scene = v), { min: 9, step: 8 }), + "Length of the generated clip, in frames.")); + out.append(field("Frame rate", numberInput(p.frame_rate, (v) => commit((pr) => pr.frame_rate = v), { min: 1 }))); + wrap.append(out); + + wrap.append(el("div", "sw-rows-label", "Prompt")); + const pr = el("div", "sw-rows"); + pr.append(field("Negative prompt", textInput(p.negative_prompt, (v) => commit((proj) => proj.negative_prompt = v)), + "Passed to the negative conditioning for every run.")); + wrap.append(pr); + + wrap.append(el("div", "sw-hint", + "Studio/Chain Sampler/continuity controls, including Seed, live under Engine ▸ Timing " + + "& Seed (empty = a new random seed every Generate). Studio always runs in Prompt-only " + + "mode here — no refinement key, since Easy Gen has no rating UI to train one. For the " + + "full learned refiner, use the Cutting Room or the ComfyUI node graph.")); + } + + const unsub = S.subscribe(render); + render(); + return () => unsub(); + } + + window.SettingsWindow.register({ + id: "generation", group: "Generation", order: 1, title: "Generation", + subtitle: "Output size, length, and negative prompt for this project.", + keywords: "width height frames frame rate negative prompt generation seed engine", + iconBg: "linear-gradient(180deg,#ffb64d,#e07f1f)", + icon: '', + mount, + }); +})(); diff --git a/easy_gen/frontend/easy_shortcuts_settings.js b/easy_gen/frontend/easy_shortcuts_settings.js new file mode 100644 index 00000000..b6837aab --- /dev/null +++ b/easy_gen/frontend/easy_shortcuts_settings.js @@ -0,0 +1,156 @@ +// Easy Gen's "Shortcuts" settings section: prompt autocomplete + anchor toggles +// (same per-browser settings/localStorage key as the Editor's editor_settings.js, +// via Store.getEditorSetting/setEditorSetting) plus the shortcut revolver +// (server-side, shared with the Editor and with generation regardless of which +// UI queues the run). Deliberately NOT the whole of editor_settings.js — this +// skips "Shortcut ideas" (not wanted here) and the "Anchor as guide" custom-node +// bypass section (Easy Gen never creates anchor_guide scenes, so it would be +// dead UI). revolverSection() below is intentionally a copy of +// editor_settings.js's — it's small and purely API-driven (no Store deps beyond +// dom/API), so duplicating it here keeps Easy Gen's settings free of the +// Editor-only sections above without forcing a shared-module refactor. +(function () { + const { el } = window.dom; + const S = window.Store; + const API = window.MovieEditorAPI; + + function toggleRow(label, hint, key) { + const row = el("div", "es-row"); + const lbl = el("label", "chk es-toggle"); + const cb = el("input"); cb.type = "checkbox"; cb.checked = !!S.getEditorSetting(key); + cb.onchange = () => S.setEditorSetting(key, cb.checked); + lbl.append(cb, el("span", null, label)); + row.append(lbl); + if (hint) row.append(el("div", "es-hint", hint)); + return row; + } + + function revolverSection() { + const wrap = el("div", "es-section"); + wrap.append(el("div", "es-section-title", "Shortcut revolver")); + wrap.append(el("div", "es-hint", + "For shortcuts with several replacements: remember which replacement fired and don't reuse it " + + "until the WHOLE set has fired. Off = seeded random pick (may repeat). Saved with your shortcut " + + "library — applies to every browser and to generation.")); + + const main = el("label", "chk es-toggle"); + const mainCb = el("input"); mainCb.type = "checkbox"; mainCb.disabled = true; + main.append(mainCb, el("span", null, "Shortcut revolver")); + wrap.append(main); + + const sub = el("div", "es-subfields"); + const rand = el("label", "chk es-toggle"); + const randCb = el("input"); randCb.type = "checkbox"; randCb.disabled = true; + rand.append(randCb, el("span", null, "Random")); + sub.append(rand); + sub.append(el("div", "es-hint", + "Unchecked: replacements fire in order — first, second, … last, then the cycle restarts. " + + "Checked: any order, but still no repeats until the set is exhausted. Changing either " + + "option restarts all cycles.")); + wrap.append(sub); + + const apply = (s) => { + mainCb.checked = !!(s && s.enabled); + randCb.checked = !!(s && s.random); + mainCb.disabled = randCb.disabled = false; + sub.style.display = mainCb.checked ? "" : "none"; + }; + + const save = () => { + mainCb.disabled = randCb.disabled = true; + API.setRevolverSettings({ enabled: mainCb.checked, random: randCb.checked }) + .then(apply) + .catch(() => { API.revolverSettings().then(apply).catch(() => {}); }); + }; + mainCb.onchange = save; + randCb.onchange = save; + + sub.style.display = "none"; + API.revolverSettings().then(apply).catch(() => { + wrap.append(el("div", "es-hint", "Could not load revolver settings.")); + }); + return wrap; + } + + // Import/export the shortcut and transition ("prompt split") JSON libraries — one + // shared FunPack library, same files the ComfyUI node graph and the Movie Editor + // read/write. Easy Gen had no UI for this at all, so a fresh install or a browser + // with an empty library had no way to load a shortcuts/transitions file. + function ioRow(label, hint, onImport, exportUrl) { + const row = el("div", "es-row"); + row.append(el("div", "es-toggle", label)); + if (hint) row.append(el("div", "es-hint", hint)); + + const modeSel = el("select", "es-io-mode"); + const optMerge = el("option", null, "Merge"); optMerge.value = "merge"; + const optReplace = el("option", null, "Replace all"); optReplace.value = "replace"; + modeSel.append(optMerge, optReplace); + + const fileInput = el("input"); + fileInput.type = "file"; fileInput.accept = ".json,application/json"; fileInput.style.display = "none"; + fileInput.onchange = async () => { + const file = fileInput.files?.[0]; + fileInput.value = ""; + if (!file) return; + const mode = modeSel.value; + if (mode === "replace" + && !confirm(`This replaces the ENTIRE ${label.toLowerCase()} library with the imported file. Continue?`)) return; + try { + const n = await onImport(file, mode); + alert(`Imported ${n} ${label.toLowerCase()}.`); + } catch (e) { alert("Import failed: " + (e.message || e)); } + }; + + const importBtn = el("button", "btn ghost tiny", "Import…"); + importBtn.type = "button"; + importBtn.onclick = () => fileInput.click(); + + const exportBtn = el("button", "btn ghost tiny", "Export"); + exportBtn.type = "button"; + exportBtn.onclick = () => window.open(exportUrl(), "_blank"); + + const controls = el("div", "es-io-controls"); + controls.append(modeSel, importBtn, exportBtn, fileInput); + row.append(controls); + return row; + } + + function librarySection() { + const wrap = el("div", "es-section"); + wrap.append(el("div", "es-section-title", "Shortcut & transition libraries")); + wrap.append(el("div", "es-hint", + "Load a shortcuts/transitions JSON file exported from the ComfyUI node graph or the Movie " + + "Editor, or export what's currently loaded. Merge overlays onto the existing library " + + "(same trigger name overwrites); Replace all wipes it first.")); + wrap.append(ioRow("Shortcuts", "Prompt-expansion triggers.", + (file, mode) => S.importShortcuts(file, mode), () => API.exportShortcutsUrl())); + wrap.append(ioRow("Transitions", "Split/cut trigger words used to break the global prompt into scenes.", + (file, mode) => S.importTransitions(file, mode), () => API.exportTransitionsUrl())); + return wrap; + } + + function mount(body) { + const content = el("div", "es-content"); + content.append(toggleRow( + "Prompt autocomplete", + "Suggest matching shortcuts while you type. Shows the trigger, its prompt, and category.", + "autocomplete")); + content.append(toggleRow( + "Use anchor", + "Text before the first split trigger (\"cut\", etc.) is a shared anchor prepended to every " + + "scene. Turn off to make that leading text the first scene instead.", + "anchorEnabled")); + content.append(revolverSection()); + content.append(librarySection()); + body.append(content); + } + + window.SettingsWindow.register({ + id: "shortcuts", group: "Generation", order: 2, title: "Shortcuts", + subtitle: "Autocomplete, anchor text, the shortcut revolver, and library import/export — shared with the Editor.", + keywords: "autocomplete anchor shortcuts split cut revolver random replacements cycle import export json library transitions", + iconBg: "linear-gradient(180deg,#6aa9ff,#3b6fd9)", + icon: '', + mount, + }); +})(); diff --git a/easy_gen/frontend/easy_store.js b/easy_gen/frontend/easy_store.js new file mode 100644 index 00000000..c1deaafa --- /dev/null +++ b/easy_gen/frontend/easy_store.js @@ -0,0 +1,368 @@ +// Minimal state container for Easy Gen — NOT the Editor's store.js (that's a +// 4000+ line timeline/composer/overlay engine tightly coupled to the full NLE +// DOM). This is a small pub-sub wrapper around the same /funpack/movie/api/* +// endpoints, but it DOES support the same one-prompt-splits-into-N-scenes +// flow as the Editor's Composer — the user only ever sees one textarea, the +// resulting scenes are never surfaced in any per-scene UI, and Generate always +// runs the whole project as one chain (no scene_ids filter), so N scenes come +// back as one stitched video with no manual cut/trim ability, by design. +// +// Exposed as window.Store (same global name the Editor uses) purely so +// reused sections — models.js, autocomplete.js, and the built-in "Refinement +// & Taste" / "Updates & ComfyUI" sections baked into settings_window.js — +// work unmodified: they all read window.Store.get().project/.shortcuts/etc. +// and call window.Store.subscribe(fn) without needing to know which app they're in. +(function () { + const API = window.MovieEditorAPI; + + const state = { + project: null, + health: null, + resetSessionArmed: false, + shortcuts: [], // prompt shortcut library (for autocomplete) + transitions: [], // transition trigger library (for split-marker inference) + mediaBin: [], // uploaded assets [{id,name,kind,...}] — feeds engine_settings.js's MediaPicker + }; + const listeners = new Set(); + + function get() { return state; } + + function notify() { listeners.forEach((fn) => { try { fn(state); } catch (_) {} }); } + + function subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + } + + function set(patch) { + Object.assign(state, patch); + notify(); + } + + async function refreshHealth() { + try { set({ health: await API.health() }); } catch (_) { set({ health: { ok: false } }); } + } + + // ── per-browser editor settings ────────────────────────────────────────── + // Same localStorage key/shape as movie_editor/frontend/store.js so toggling + // "Prompt autocomplete" or "Use anchor" is shared between the Editor and + // Easy Gen on the same browser (matches the Editor's own cross-entry-point + // behavior — these are per-browser prompt-editing prefs, not project data). + const EDITOR_SETTINGS_KEY = "funpack_editor_settings"; + const EDITOR_SETTINGS_DEFAULTS = { autocomplete: true, anchorEnabled: true }; + function _loadEditorSettings() { + try { + const raw = JSON.parse(localStorage.getItem(EDITOR_SETTINGS_KEY) || "{}"); + return { ...EDITOR_SETTINGS_DEFAULTS, ...(raw && typeof raw === "object" ? raw : {}) }; + } catch (_) { return { ...EDITOR_SETTINGS_DEFAULTS }; } + } + let _editorSettings = _loadEditorSettings(); + function getEditorSetting(key) { return _editorSettings[key]; } + function setEditorSetting(key, val) { + if (_editorSettings[key] === val) return; + _editorSettings = { ..._editorSettings, [key]: val }; + try { localStorage.setItem(EDITOR_SETTINGS_KEY, JSON.stringify(_editorSettings)); } catch (_) {} + notify(); + if (key === "anchorEnabled" && state.project && (state.project.global_prompt || "").trim()) { + applyGlobalPrompt(state.project.global_prompt); + } + } + + async function loadLibraries() { + try { const r = await API.shortcuts(); set({ shortcuts: r.shortcuts || [] }); } catch (_) {} + try { const r = await API.transitions(); set({ transitions: r.transitions || r || [] }); } catch (_) {} + } + + async function loadMedia() { + try { set({ mediaBin: (await API.listMedia()).media || [] }); } catch (_) { set({ mediaBin: [] }); } + } + + // Import/export the same shortcut/transition JSON libraries the ComfyUI node graph + // and the Movie Editor read and write (one shared FunPack library, no Easy Gen copy). + // mode: "merge" (overlay onto the existing library) | "replace" (wipe first). + async function importShortcuts(file, mode) { + const data = JSON.parse(await file.text()); + const r = await API.importShortcuts(data, mode); + set({ shortcuts: r.shortcuts || state.shortcuts }); + return r.imported; + } + async function importTransitions(file, mode) { + const data = JSON.parse(await file.text()); + const r = await API.importTransitions(data, mode); + set({ transitions: r.transitions || state.transitions }); + return r.imported; + } + + function emptyScene() { + return { text: "", source: { type: "empty" } }; + } + + async function listProjects() { + return (await API.listProjects()).projects || []; + } + + async function createProject(name) { + const p = await API.createProject(name || "Untitled"); + p.scenes = [emptyScene()]; + const saved = await API.saveProject(p.id, p); + set({ project: saved }); + return saved; + } + + // Fallback for projects saved before the global-prompt split existed, or + // edited scene-by-scene in the Editor without ever using its Composer: + // reconstruct a verbatim prompt from anchor + scene texts + transition + // markers so the textarea shows something sensible instead of going blank. + function buildPromptFallback(p) { + const parts = []; + const anchor = (p.anchor || "").trim(); + if (anchor) parts.push(anchor); + (p.scenes || []).forEach((s, i) => { + if (i > 0) parts.push((p.scenes[i - 1].transition_to_next || "cut").trim()); + const t = (s.text || "").trim(); + if (t) parts.push(t); + }); + return parts.filter(Boolean).join(" "); + } + + async function loadProject(id) { + const p = await API.getProject(id); + if (!p.scenes || !p.scenes.length) p.scenes = [emptyScene()]; + if (!(p.global_prompt || "").trim()) p.global_prompt = buildPromptFallback(p); + set({ project: p }); + return p; + } + + async function deleteProject(id) { + await API.deleteProject(id); + if (state.project && state.project.id === id) set({ project: null }); + } + + // ── global prompt → N scenes (mirrors the Editor's Composer Apply, minus + // the timeline/ghost/history machinery Easy Gen has no use for) ────────── + + // Ported from movie_editor/frontend/store.js's _normalizeGlobalPromptParse: + // folds the parser's "anchor" back into Scene 1 when the anchor setting is off. + function _normalizeGlobalPromptParse(trimmed, v) { + v = v || {}; + const text = String(trimmed || "").trim(); + const anchor = String(v.anchor || "").trim(); + const scenes = Array.isArray(v.scenes) ? v.scenes : []; + const hasSceneText = scenes.some((s) => String(s.text || "").trim()); + + if (!hasSceneText && text) return { anchor: "", scenes: [{ text }] }; + if (!hasSceneText && anchor) return { anchor: "", scenes: [{ text: anchor }] }; + if (!_editorSettings.anchorEnabled && anchor) { + return { anchor: "", scenes: [{ text: anchor }, ...scenes] }; + } + return { anchor: v.anchor || "", scenes }; + } + + // Ported from movie_editor/frontend/store.js's _inferSplitMarkersFromPrompt: + // finds which transition trigger (if any) sits in the raw gap text between + // two consecutive verbatim scene chunks, so the chain sampler gets the same + // seam behavior ("cut", etc.) the Editor would have produced. + function _inferSplitMarkers(scenes, fullPrompt) { + if (!fullPrompt || !scenes.length) return; + const markers = (state.transitions || []).filter((m) => m.enabled !== false && (m.trigger || m.name)); + let pos = 0; + for (let i = 0; i < scenes.length; i++) { + const t = scenes[i].text || ""; + if (!t) continue; + const idx = fullPrompt.indexOf(t, pos); + if (idx < 0) continue; + const endOfScene = idx + t.length; + if (i < scenes.length - 1) { + const nextT = scenes[i + 1].text || ""; + const nextIdx = nextT ? fullPrompt.indexOf(nextT, endOfScene) : endOfScene; + const gapEnd = nextIdx >= 0 ? nextIdx : fullPrompt.length; + const gap = fullPrompt.slice(endOfScene, gapEnd); + for (const m of markers) { + const trig = m.trigger || m.name; + if (trig && gap.includes(trig)) { scenes[i].transition_to_next = trig; break; } + } + } + pos = endOfScene; + } + } + + // Sequence guard: if the user keeps typing while a parse is in flight, an + // older (now-stale) response must not overwrite scenes built from newer text. + let _applySeq = 0; + + // Splits `text` into project.anchor + project.scenes via the same backend + // parser the Editor's Composer uses (POST /projects/{id}/parse, lossless — + // no shortcut expansion, no scene UI). Scene 0 keeps whatever upload/guide + // it already had; new scenes 1..N-1 default to "carry" (continue the chain), + // matching how the Editor treats anchorless continuation scenes. + async function applyGlobalPrompt(text) { + if (!state.project) return false; + const trimmed = String(text || "").trim(); + state.project.global_prompt = trimmed; + if (!trimmed) { + const prevSource = state.project.scenes[0]?.source || { type: "empty" }; + state.project.scenes = [{ text: "", source: prevSource }]; + state.project.anchor = ""; + notify(); + return true; + } + const seq = ++_applySeq; + let res; + try { res = await API.parsePrompt(state.project.id, trimmed); } + catch (e) { console.warn("Prompt parse failed:", e); return false; } + if (seq !== _applySeq) return false; // superseded by a newer keystroke + if (!state.project || state.project.global_prompt !== trimmed) return false; // project changed underneath us + const v = _normalizeGlobalPromptParse(trimmed, res.parsed_verbatim || res.parsed_raw || res.parsed || {}); + if (!(v.scenes || []).length) return false; + const prevSource = state.project.scenes[0]?.source || { type: "empty" }; + state.project.anchor = v.anchor || ""; + state.project.scenes = v.scenes.map((s, i) => ({ + text: String(s.text || "").trim(), + transition_to_next: "", + source: i === 0 ? prevSource : { type: "carry" }, + })); + _inferSplitMarkers(state.project.scenes, trimmed); + notify(); + return true; + } + + let _applyTimer = null; + function scheduleGlobalPromptApply(text) { + if (_applyTimer) clearTimeout(_applyTimer); + _applyTimer = setTimeout(() => { applyGlobalPrompt(text).then(() => save().catch(() => {})); }, 500); + } + + // kind: "image" | "video" | null (null clears the upload → pure t2v). + function setSceneMedia(mediaId, kind) { + if (!state.project) return; + state.project.scenes[0].source = mediaId + ? { type: kind === "video" ? "v2v" : "image", media_ref: mediaId } + : { type: "empty" }; + notify(); + } + + // Two overlapping save() calls (e.g. a debounced auto-save from an earlier + // edit still in flight when a later action calls save() directly) can land + // on the server out of order — whichever PUT is written to disk last wins, + // even if it was the older/now-stale one, silently reverting a newer edit. + // Serializing through one chain means a save only starts once the previous + // one has FULLY completed, and reads state.project live at that point, so + // it always picks up whatever changed while it waited. + let saveQueue = Promise.resolve(); + function save() { + const run = saveQueue.then(async () => { + if (!state.project) return null; + // Models & Pipeline (models.js) saves bypass/expose/widget edits straight to the + // server via its own PUT /projects/{id}/models call — it never touches state.project + // here. Without refetching first, this save would send project.models as it was when + // the project was last loaded, silently reverting whatever was just changed there + // (same class of bug the Editor's store.js fixed by syncing state.models into + // state.project.models before every autosave commit). + try { state.project.models = await API.getModels(state.project.id); } catch (_) {} + const saved = await API.saveProject(state.project.id, state.project); + set({ project: saved }); + return saved; + }); + saveQueue = run.catch(() => {}); // keep the queue alive even if this save failed + return run; + } + + // ── Engine settings (Studio / Chain Sampler / continuity) ────────────────── + // Minimal re-implementation of movie_editor/frontend/store.js's project-patch + // helpers, just enough for the shared engine_settings.js (loaded via the same + // frontend-fallback mechanism as autocomplete.js/suggestions.js) to run + // unmodified against Easy Gen's project. No undo/history, no global-prompt + // resync — Easy Gen's scenes come from the split parser, not manual editing. + let _engineSaveTimer = null; + function _scheduleEngineSave(ms) { + if (_engineSaveTimer) clearTimeout(_engineSaveTimer); + _engineSaveTimer = setTimeout(() => { _engineSaveTimer = null; save().catch(() => {}); }, ms); + } + + function patchProject(patch) { + if (!state.project) return; + Object.assign(state.project, patch); + notify(); + save().catch(() => {}); + } + function patchProjectQuiet(patch) { + if (!state.project) return; + Object.assign(state.project, patch); + notify(); + _scheduleEngineSave(600); + } + + function setConditioningSlot(slotId) { patchProject({ conditioning_slot: slotId || "funpack" }); } + function setSamplerSlot(slotId) { patchProject({ sampler_slot: slotId || "funpack" }); } + function setSamplerInput(name, value) { + if (!state.project) return; + patchProjectQuiet({ sampler_inputs: { ...(state.project.sampler_inputs || {}), [name]: value } }); + } + function setSamplerInputNow(name, value) { + if (!state.project) return; + patchProject({ sampler_inputs: { ...(state.project.sampler_inputs || {}), [name]: value } }); + } + function unsetSamplerInput(name) { + if (!state.project) return; + const prev = { ...(state.project.sampler_inputs || {}) }; + delete prev[name]; + patchProjectQuiet({ sampler_inputs: prev }); + } + function setStudioInput(name, value) { + if (!state.project) return; + patchProjectQuiet({ studio_inputs: { ...(state.project.studio_inputs || {}), [name]: value } }); + } + function setStudioInputNow(name, value) { + if (!state.project) return; + patchProject({ studio_inputs: { ...(state.project.studio_inputs || {}), [name]: value } }); + } + + const ENGINE_PRESETS = { + draft: { label: "Fast draft", studio_inputs: { "refiner.split_by_transitions": false }, sampler_inputs: { steps: 20 } }, + quality: { label: "Quality", studio_inputs: { "refiner.split_by_transitions": true }, sampler_inputs: { steps: 40 } }, + continuity: { + label: "Continuity-heavy", + guide_settings: { stack_enabled: true, auto_guides: true, identity_pins: true }, + continuity_settings: { enabled: true }, + sampler_inputs: { steps: 36 }, + }, + }; + function applyEnginePreset(key) { + const preset = ENGINE_PRESETS[key]; + if (!preset || !state.project) return; + const patch = {}; + if (preset.studio_inputs) patch.studio_inputs = { ...(state.project.studio_inputs || {}), ...preset.studio_inputs }; + if (preset.sampler_inputs) patch.sampler_inputs = { ...(state.project.sampler_inputs || {}), ...preset.sampler_inputs }; + if (preset.guide_settings) patch.guide_settings = { ...(state.project.guide_settings || {}), ...preset.guide_settings }; + if (preset.continuity_settings) patch.continuity_settings = { ...(state.project.continuity_settings || {}), ...preset.continuity_settings }; + patchProject(patch); + } + + function resetStudioSession() { + set({ resetSessionArmed: !state.resetSessionArmed }); + } + + // Consumed by the Generate flow: reset_session applies to the NEXT run only. + function takeResetSessionFlag() { + const armed = state.resetSessionArmed; + if (armed) set({ resetSessionArmed: false }); + return armed; + } + + window.Store = { + get, subscribe, set, + refreshHealth, + getEditorSetting, setEditorSetting, + loadLibraries, importShortcuts, importTransitions, + loadMedia, + listProjects, createProject, loadProject, deleteProject, + applyGlobalPrompt, scheduleGlobalPromptApply, + setSceneMedia, save, + patchProject, patchProjectQuiet, + setConditioningSlot, setSamplerSlot, + setSamplerInput, setSamplerInputNow, unsetSamplerInput, + setStudioInput, setStudioInputNow, + ENGINE_PRESETS, applyEnginePreset, + resetStudioSession, takeResetSessionFlag, + }; +})(); diff --git a/easy_gen/frontend/index.html b/easy_gen/frontend/index.html new file mode 100644 index 00000000..095ad4a7 --- /dev/null +++ b/easy_gen/frontend/index.html @@ -0,0 +1,105 @@ + + + + + + FunPack · Easy Gen + + + + + + + + +
+
+ ◉ FunPack Easy Gen +
No project
+ + +
+ +
+ +
+ + + +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/easy_gen/frontend/preview.js b/easy_gen/frontend/preview.js new file mode 100644 index 00000000..9902058d --- /dev/null +++ b/easy_gen/frontend/preview.js @@ -0,0 +1,196 @@ +// Big preview pane: plain